こんにちは、かつコーチです。
ここまでディレクトリ構成・Atomic Design・カスタムフックの設計パターンと、React設計の考え方を積み上げてきました。
今回はいよいよ実践編として、それらを組み合わせながらAPI連携ありのTODOアプリを作っていきます。
「作る・読む・更新する・削除する」というCRUD操作を、fetchを使った実際の通信コードとともに一通り体験してみましょう。
この記事で作るもの
完成イメージと使う技術
今回作るのは、次の機能を持つシンプルなTODOアプリです。
- TODOの一覧を取得して表示する(Read)
- 新しいTODOを追加する(Create)
- TODOの完了・未完了を切り替える(Update)
- TODOを削除する(Delete)
バックエンドは架空のREST API(https://api.example.com/todos)を想定し、Reactの標準機能であるfetchでリクエストを送ります。
状態管理はライブラリを使わず、useStateと自作のカスタムフックだけで組み立てます。
ディレクトリ構成と型定義
まずは全体の構成を決める
前回・前々回で学んだ機能別構成の考え方を踏まえて、次のような構成にします。
src/
├── features/
│ └── todo/
│ ├── api/
│ │ └── todoApi.tsx
│ ├── hooks/
│ │ └── useTodos.tsx
│ ├── components/
│ │ ├── TodoForm.tsx
│ │ ├── TodoItem.tsx
│ │ └── TodoList.tsx
│ └── types.ts
└── App.tsx
型定義を最初に決めておく
TypeScriptで実装する場合、最初にAPIのレスポンス型を決めておくと、後の実装がぶれません。
// features/todo/types.ts
export type Todo = {
id: string;
title: string;
completed: boolean;
};
export type NewTodo = {
title: string;
};
API通信部分を実装する
fetchをラップしたAPI関数を作る
コンポーネントの中に直接fetchを書くと、通信ロジックとUIロジックが混ざってしまいます。
まずはAPI関数だけを独立したファイルにまとめます。
// features/todo/api/todoApi.tsx
import type { Todo, NewTodo } from "../types";
const BASE_URL = "https://api.example.com/todos";
export const fetchTodos = async (): Promise<Todo[]> => {
const res = await fetch(BASE_URL);
if (!res.ok) throw new Error(`一覧取得に失敗しました: ${res.status}`);
return res.json();
};
export const createTodo = async (newTodo: NewTodo): Promise<Todo> => {
const res = await fetch(BASE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newTodo),
});
if (!res.ok) throw new Error(`追加に失敗しました: ${res.status}`);
return res.json();
};
export const updateTodo = async (todo: Todo): Promise<Todo> => {
const res = await fetch(`${BASE_URL}/${todo.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(todo),
});
if (!res.ok) throw new Error(`更新に失敗しました: ${res.status}`);
return res.json();
};
export const deleteTodo = async (id: string): Promise<void> => {
const res = await fetch(`${BASE_URL}/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error(`削除に失敗しました: ${res.status}`);
};
CRUDに対応する4つの関数を用意し、それぞれエラー時にはthrowするようにしています。
エラーをここで握りつぶさず投げておくことで、呼び出し側(カスタムフック)でまとめてハンドリングできます。
カスタムフックで状態管理をまとめる
一覧取得・追加・更新・削除をまとめて扱う
前回学んだ「非同期処理ラップフック」のパターンを応用し、TODOアプリ専用のカスタムフックを作ります。
// features/todo/hooks/useTodos.tsx
import { useState, useEffect, useCallback } from "react";
import type { Todo, NewTodo } from "../types";
import * as todoApi from "../api/todoApi";
export const useTodos = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadTodos = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await todoApi.fetchTodos();
setTodos(data);
} catch (e) {
setError(e instanceof Error ? e.message : "不明なエラーが発生しました");
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadTodos();
}, [loadTodos]);
const addTodo = async (newTodo: NewTodo) => {
const created = await todoApi.createTodo(newTodo);
setTodos((prev) => [...prev, created]);
};
const toggleTodo = async (todo: Todo) => {
const updated = await todoApi.updateTodo({ ...todo, completed: !todo.completed });
setTodos((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
};
const removeTodo = async (id: string) => {
await todoApi.deleteTodo(id);
setTodos((prev) => prev.filter((t) => t.id !== id));
};
return { todos, isLoading, error, addTodo, toggleTodo, removeTodo };
};
ポイントは、追加・更新・削除のたびに一覧全体を再取得するのではなく、返ってきたレスポンスや操作対象のIDを使ってローカルのstateだけを更新していることです。
一覧を毎回再取得すると通信回数が増え、体感速度も落ちてしまいます。
コンポーネントを組み立てる
TodoForm:追加フォーム
// features/todo/components/TodoForm.tsx
import { useState, FormEvent } from "react";
type Props = {
onAdd: (title: string) => void;
};
export const TodoForm = ({ onAdd }: Props) => {
const [title, setTitle] = useState("");
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
onAdd(title);
setTitle("");
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="やることを入力"
/>
<button type="submit">追加</button>
</form>
);
};
TodoItem・TodoList:一覧の表示
// features/todo/components/TodoItem.tsx
import type { Todo } from "../types";
type Props = {
todo: Todo;
onToggle: (todo: Todo) => void;
onRemove: (id: string) => void;
};
export const TodoItem = ({ todo, onToggle, onRemove }: Props) => {
return (
<li>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo)}
/>
<span style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
{todo.title}
</span>
</label>
<button onClick={() => onRemove(todo.id)}>削除</button>
</li>
);
};
// features/todo/components/TodoList.tsx
import type { Todo } from "../types";
import { TodoItem } from "./TodoItem";
type Props = {
todos: Todo[];
onToggle: (todo: Todo) => void;
onRemove: (id: string) => void;
};
export const TodoList = ({ todos, onToggle, onRemove }: Props) => {
if (todos.length === 0) {
return <p>やることはまだ登録されていません。</p>;
}
return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={onToggle} onRemove={onRemove} />
))}
</ul>
);
};
App:全体を組み立てる
// App.tsx
import { useTodos } from "./features/todo/hooks/useTodos";
import { TodoForm } from "./features/todo/components/TodoForm";
import { TodoList } from "./features/todo/components/TodoList";
function App() {
const { todos, isLoading, error, addTodo, toggleTodo, removeTodo } = useTodos();
if (isLoading) return <p>読み込み中です…</p>;
if (error) return <p>エラーが発生しました:{error}</p>;
return (
<div>
<h1>TODOリスト</h1>
<TodoForm onAdd={(title) => addTodo({ title })} />
<TodoList todos={todos} onToggle={toggleTodo} onRemove={removeTodo} />
</div>
);
}
export default App;
コンポーネント側は状態の中身や通信の詳細を知らず、useTodosが返す値と関数を受け取るだけになっているのが分かると思います。
つまずきやすいポイント:楽観的更新と実際の反映のズレ
チェック操作がワンテンポ遅れて感じた
このTODOアプリを最初に作ったとき、チェックボックスをクリックしてからUIに反映されるまでにワンテンポの遅れがあり、「アプリが重い」という印象を持たれてしまったことがあります。
原因は、toggleTodoがAPIのレスポンスを待ってからstateを更新する実装になっていたためでした。
❌ Before:APIのレスポンスを待ってから画面を更新する
const toggleTodo = async (todo: Todo) => {
// このawaitの間、ユーザーの画面には何も変化がない
const updated = await todoApi.updateTodo({ ...todo, completed: !todo.completed });
setTodos((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
};
✅ After:先に画面を更新し、失敗時だけ元に戻す(楽観的更新)
const toggleTodo = async (todo: Todo) => {
const optimisticTodo = { ...todo, completed: !todo.completed };
// 先にUIを更新して、体感速度を優先する
setTodos((prev) => prev.map((t) => (t.id === todo.id ? optimisticTodo : t)));
try {
const updated = await todoApi.updateTodo(optimisticTodo);
setTodos((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
} catch {
// 失敗した場合だけ元の状態に戻す
setTodos((prev) => prev.map((t) => (t.id === todo.id ? todo : t)));
setError("更新に失敗しました。もう一度お試しください。");
}
};
この「先にUIを反映し、失敗したときだけロールバックする」手法は楽観的更新(Optimistic Update)と呼ばれ、チェック・いいねのようなライトな操作でよく使われます。
体感速度が大きく改善される一方、失敗時のロールバック処理を必ずセットで実装しないと、サーバーとクライアントの状態がずれたままになってしまう点には注意が必要です。
応用:React Query・SWRへの発展
自前実装の限界
ここまでuseStateとカスタムフックだけでAPI連携を実装してきましたが、キャッシュ管理・再取得のタイミング調整・楽観的更新のロールバックといった処理は、規模が大きくなるほど自前実装がつらくなっていきます。
実務では、こうした処理を肩代わりしてくれるReact Query(TanStack Query)やSWRといったライブラリがよく使われます。
まずは今回のように自前で実装してAPI通信の流れを理解したうえで、こうしたライブラリに乗り換えると、内部で何が行われているかを理解した状態で使えるようになります。
まとめ
この記事のポイント
- API通信は
api/層に切り出し、コンポーネントやフックから通信の詳細を隠蔽する - カスタムフックにCRUD操作をまとめることで、コンポーネントは表示に専念できる
- 追加・更新・削除のたびに一覧を再取得せず、ローカルstateを直接更新すると体感速度が上がる
- チェックのような軽い操作は楽観的更新を使うと、UIの反応がさらに速く感じられる
- 自前実装の限界を知ったうえで、React QueryやSWRへの発展を検討するとよい
次に読むべき記事
最後は、状態管理の主要な選択肢を同じアプリで実際に使い比べてみます。
→ 次の記事:Redux・Zustand・Context APIを同じアプリで実際に使い比べてみた