この課題のねらい
ここはいよいよ「実用的 React」の集大成です。
テーマはミニ TODO アプリ──追加・削除・完了状態管理までを、自分の手で通しで作ること。
state 設計
親子コンポーネントと props
イベント処理(追加・削除・トグル)
これらが一気につながるので、「React でアプリを作るってこういうことか」がかなりクリアになります。
TODOアプリの全体像を決める
どんな機能を持たせるか
今回のミニアプリの要件はこうです。
- TODO をテキストで追加できる
- TODO を削除できる
- TODO に「完了フラグ」を持たせて、完了/未完了を切り替えられる
画面イメージとしては、
- 上に入力フォーム+追加ボタン
- 下に TODO の一覧
- 各 TODO に「完了チェックボックス」と「削除ボタン」
くらいをイメージしておくといいです。
状態設計(どこに何を持つか)
状態はこう設計します。
todos(TODO の配列)- 1件は
{ id, title, completed }の形 - 親コンポーネント(
TodoApp)が持つ
- 1件は
- 入力中のテキスト
- フォームコンポーネント(
TodoInput)が持つ
- フォームコンポーネント(
図にするとこんな感じです。
TodoApp
├─ state: todos
├─ handleAddTodo(title)
├─ handleToggleTodo(id)
├─ handleDeleteTodo(id)
├─ TodoInput(追加用フォーム)
└─ TodoList(一覧表示)
└─ TodoItem(1件分)
必須課題① TODOの追加機能
親コンポーネントでtodosを管理する
まずは親コンポーネント TodoApp を作ります。
"use client";
import { useState } from "react";
import { TodoInput } from "./TodoInput";
import { TodoList } from "./TodoList";
type Todo = {
id: number;
title: string;
completed: boolean;
};
export default function TodoApp() {
const [todos, setTodos] = useState<Todo[]>([]);
function handleAddTodo(title: string) {
const newTodo: Todo = {
id: Date.now(),
title,
completed: false,
};
setTodos((prev) => [...prev, newTodo]);
}
return (
<main style={{ padding: "24px", fontFamily: "sans-serif" }}>
<h1>TODO アプリ</h1>
<TodoInput onAdd={handleAddTodo} />
<TodoList todos={todos} />
</main>
);
}
TSXここでのポイントは、
todosはアプリ全体で共有したいので、親のTodoAppが持つhandleAddTodoで新しい TODO を作り、setTodosで配列に追加する- 追加処理は
TodoInputから呼び出せるように、onAddとして渡す
入力フォームコンポーネントを作る
次に、TodoInput を作ります。
// TodoInput.tsx
"use client";
import { useState } from "react";
type TodoInputProps = {
onAdd: (title: string) => void;
};
export function TodoInput({ onAdd }: TodoInputProps) {
const [value, setValue] = useState("");
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmed = value.trim();
if (!trimmed) return;
onAdd(trimmed);
setValue("");
}
return (
<form onSubmit={handleSubmit} style={{ marginTop: "16px" }}>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="TODO を入力"
style={{ padding: "4px 8px", width: "220px" }}
/>
<button
type="submit"
style={{
marginLeft: "8px",
padding: "4px 12px",
borderRadius: "9999px",
border: "none",
backgroundColor: "#3b82f6",
color: "white",
cursor: "pointer",
}}
>
追加
</button>
</form>
);
}
TSXここでやっていることは、
- 入力中のテキストは
TodoInput自身の state(value)で管理 - 送信時に
onAdd(value)を呼んで、親に「このタイトルで TODO 追加して」と依頼 - 追加後は
setValue("")で入力欄をクリア
「入力状態はフォームが持つ」「TODO の配列は親が持つ」という役割分担が大事です。
必須課題② TODOの削除機能
削除用のハンドラを親に用意する
TodoApp に削除用の関数を追加します。
function handleDeleteTodo(id: number) {
setTodos((prev) => prev.filter((todo) => todo.id !== id));
}
TSXそして、TodoList に onDelete として渡します。
<TodoList todos={todos} onDelete={handleDeleteTodo} />
TSX一覧表示コンポーネントで削除ボタンを作る
TodoList と TodoItem を作ります。
// TodoList.tsx
type Todo = {
id: number;
title: string;
completed: boolean;
};
type TodoListProps = {
todos: Todo[];
onDelete: (id: number) => void;
onToggle: (id: number) => void;
};
export function TodoList({ todos, onDelete, onToggle }: TodoListProps) {
if (todos.length === 0) {
return <p style={{ marginTop: "16px" }}>TODO はまだありません。</p>;
}
return (
<div style={{ marginTop: "16px" }}>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onDelete={onDelete}
onToggle={onToggle}
/>
))}
</div>
);
}
TSX// TodoItem.tsx
type Todo = {
id: number;
title: string;
completed: boolean;
};
type TodoItemProps = {
todo: Todo;
onDelete: (id: number) => void;
onToggle: (id: number) => void;
};
export function TodoItem({ todo, onDelete, onToggle }: TodoItemProps) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "4px 0",
}}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span
style={{
textDecoration: todo.completed ? "line-through" : "none",
color: todo.completed ? "#9ca3af" : "#111827",
}}
>
{todo.title}
</span>
<button
onClick={() => onDelete(todo.id)}
style={{
marginLeft: "auto",
padding: "2px 8px",
borderRadius: "9999px",
border: "none",
backgroundColor: "#ef4444",
color: "white",
cursor: "pointer",
fontSize: "12px",
}}
>
削除
</button>
</div>
);
}
TSX削除の流れはこうです。
- ユーザーが「削除」ボタンを押す
TodoItemからonDelete(todo.id)が呼ばれるTodoList経由でTodoAppのhandleDeleteTodoが呼ばれるsetTodosで該当 ID の TODO を配列から除外
「削除の決定権は親(TodoApp)にある」という構造がポイントです。
挑戦課題 完了状態管理
completedフラグをトグルする
完了状態は、Todo 型にすでに completed: boolean として持たせています。
あとは「チェックボックスを押したら true/false を切り替える」処理を書くだけです。
TodoApp にトグル用の関数を追加します。
function handleToggleTodo(id: number) {
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}
TSXそして、TodoList に onToggle として渡します。
<TodoList todos={todos} onDelete={handleDeleteTodo} onToggle={handleToggleTodo} />
TSXTodoItem では、チェックボックスと見た目を completed に応じて変えています。
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span
style={{
textDecoration: todo.completed ? "line-through" : "none",
color: todo.completed ? "#9ca3af" : "#111827",
}}
>
{todo.title}
</span>
TSXこれで、
- チェックを入れると
completed: trueになり、取り消し線+グレー表示 - チェックを外すと
completed: falseに戻り、通常表示
という「完了状態管理」が実現できます。
まとめ:ミニ TODO アプリでつかんでほしいこと
このミニアプリで押さえてほしいのは、次の感覚です。
- TODO の配列(
todos)は親コンポーネントが一元管理する - 追加・削除・完了トグルといった「状態を変える処理」は、全部親に置く
- 子コンポーネントは「入力フォーム」「表示」「ボタン押下のきっかけ」を担当し、実際の状態変更は props 経由で親に任せる
- 完了状態のようなフラグは、見た目(取り消し線・色)としっかり結びつけてあげると、UX が一気に上がる
ここまで通せていたら、もう「おもちゃのカウンター」ではなく、
立派に「小さなプロダクト」を自分で組み立てられている状態です。
