ここで作るTODOアプリのゴール
ここまでで、state・props・イベント・フォーム・配列表示・状態設計など、React / Next.js の「部品」は一通り触ってきました。
ここではそれを全部まとめて、超定番の「TODOアプリ」を作りながら、状態管理の総復習をします。
やることはシンプルです。
- テキストを入力して「追加」ボタンでTODOを追加する
- 追加されたTODOを一覧表示する
- 各TODOの「削除」ボタンで消せるようにする
この小さなアプリの中に、実務レベルの基本がかなり詰まっています。
TODOアプリの最小構成を決める
どんな状態が必要かを先に言葉で整理する
コードを書く前に、「どんな状態が必要か」を言葉で整理してみます。
- いま入力欄に書かれているテキスト
- 追加されたTODOの一覧(配列)
この 2 つがあれば、TODOアプリとして成立します。
「入力欄の中身」はフォームの state。
「TODOの一覧」は配列の state。
どちらも「このページ全体で使うもの」なので、ページコンポーネントに持たせるのが自然です。
TODOアプリの基本実装
完成形のコードを先に見る
まずは、1 ファイルで完結するシンプルな実装を出します。
"use client";
import { useState } from "react";
type Todo = {
id: number;
text: string;
};
export default function TodoPage() {
const [text, setText] = useState("");
const [todos, setTodos] = useState<Todo[]>([]);
const [nextId, setNextId] = useState(1);
function handleAdd(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed) {
return;
}
const newTodo: Todo = {
id: nextId,
text: trimmed,
};
setTodos([...todos, newTodo]);
setNextId(nextId + 1);
setText("");
}
function handleDelete(id: number) {
setTodos(todos.filter((todo) => todo.id !== id));
}
return (
<main style={{ maxWidth: "480px", margin: "24px auto", fontFamily: "sans-serif" }}>
<h1>TODOアプリ</h1>
<p>やることを追加して、終わったら削除していきましょう。</p>
<form onSubmit={handleAdd} style={{ marginTop: "16px", display: "flex", gap: "8px" }}>
<input
type="text"
placeholder="やることを入力..."
value={text}
onChange={(e) => setText(e.target.value)}
style={{ flex: 1, padding: "8px" }}
/>
<button type="submit">追加</button>
</form>
<section style={{ marginTop: "24px" }}>
<h2>TODO一覧</h2>
{todos.length === 0 ? (
<p style={{ color: "#6b7280" }}>まだTODOはありません。</p>
) : (
todos.map((todo) => (
<div
key={todo.id}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "8px 12px",
marginBottom: "8px",
backgroundColor: "white",
borderRadius: "4px",
boxShadow: "0 1px 2px rgba(15,23,42,0.08)",
}}
>
<span>{todo.text}</span>
<button
onClick={() => handleDelete(todo.id)}
style={{ fontSize: "12px", color: "#b91c1c" }}
>
削除
</button>
</div>
))
)}
</section>
</main>
);
}
TSXここから、重要なポイントを一つずつ分解していきます。
追加処理の設計と実装
入力欄の管理(input管理の復習)
text という state が、入力欄の中身を表しています。
const [text, setText] = useState("");
TSXそして、input にはこう書いています。
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
TSXこれは「制御されたコンポーネント」の基本形です。
- 入力欄の表示内容は
textによって決まる - ユーザーが入力するたびに
setTextで state を更新する
「入力欄の中身=state」という関係を作ることで、
送信時に text を使って新しい TODO を作れるようになります。
form と onSubmit で「追加」を扱う
追加処理は、<form> の onSubmit で扱っています。
<form onSubmit={handleAdd}>
...
</form>
TSXhandleAdd の中身を見てみましょう。
function handleAdd(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed) {
return;
}
const newTodo: Todo = {
id: nextId,
text: trimmed,
};
setTodos([...todos, newTodo]);
setNextId(nextId + 1);
setText("");
}
TSXここでやっていることを順番に整理します。
まず e.preventDefault() でブラウザのデフォルト送信(ページリロード)を止める。text.trim() で前後の空白を削り、空文字なら何もしないで return。
新しい TODO オブジェクトを作る(id と text を持つ)。setTodos([...todos, newTodo]) で配列の末尾に追加した新しい配列を作る。setNextId(nextId + 1) で次に使う ID を更新する。setText("") で入力欄を空に戻す。
ここで特に大事なのは、「配列を直接書き換えない」ことです。
// これはダメ(配列を直接変更している)
todos.push(newTodo);
setTodos(todos);
TSXではなく、
setTodos([...todos, newTodo]);
TSXのように、「新しい配列を作って渡す」のが React の基本ルールです。
これを守ることで、React が「配列が変わった」と正しく検知できます。
削除処理の設計と実装
削除は「残すものだけを残す」
削除処理は、handleDelete という関数で実装しています。
function handleDelete(id: number) {
setTodos(todos.filter((todo) => todo.id !== id));
}
TSX考え方はシンプルで、
- 削除したい TODO の id を受け取る
- その id 以外の TODO だけを残した新しい配列を作る
- それを
setTodosに渡す
という流れです。
filter は「条件に合う要素だけを残した新しい配列を返す」メソッドなので、
「削除したいもの以外を残す」という書き方が自然にできます。
削除ボタンから id を渡す
各 TODO の表示部分では、こう書いています。
<button onClick={() => handleDelete(todo.id)}>削除</button>
TSXここで、
todo.idをhandleDeleteに渡すhandleDeleteの中で、その id を使って配列をフィルタする
という流れになっています。
「どの TODO を削除したいのか」を識別するために、id を持たせておくことが重要です。
配列の index でも一応できますが、並び替えや挿入が入るとバグの元になるので、
基本的には「一意な id」を持たせるのが実務寄りです。
状態管理の総復習として見る
このTODOアプリに出てくる「状態」を整理する
この小さなアプリの中に、実は状態管理のエッセンスが詰まっています。
text… フォームの入力値(ユーザーの一時的な入力)todos… アプリのメインデータ(TODO の一覧)nextId… 新しい TODO に割り当てるためのカウンタ
それぞれ役割が違います。
text は UI に強く結びついた「一時的な状態」。todos は画面の主役となる「リストデータ」。nextId は内部的な管理用の状態(ユーザーには見えない)。
このように、「状態の種類」を意識して分けて考えると、
どこに何を置くべきかが見えやすくなります。
state の持ち場所はどこが正しいか
今回の例では、すべての state を TodoPage コンポーネントに置いています。
理由はシンプルで、
- 入力欄も
- TODO一覧も
- 追加・削除のロジックも
すべてこのページの中で完結しているからです。
もしこれをさらに分割するなら、
- 入力フォーム部分を
TodoFormコンポーネントに切り出す - TODO一覧部分を
TodoListコンポーネントに切り出す
という形になりますが、その場合でも
todosとsetTodos(あるいはonAdd,onDelete)は親が持つ- 子は「表示」と「イベントのきっかけ」だけを担当する
という構造にするのが基本です。
実務レベルに近づくための視点
「ハッピーケースだけで終わらせない」
今回の TODO アプリは、まだかなりシンプルです。
実務に近づけるなら、例えばこんな要素を足していきます。
- ローカルストレージに保存して、リロードしても TODO が残る
- TODO に「完了フラグ」を付けて、完了・未完了を切り替えられる
- フィルタ(すべて / 未完了 / 完了)を付ける
- 入力チェックをもう少し厳密にする(文字数制限など)
これらを足していくと、
- useEffect(初回読み込み・保存)
- 状態設計(どこに何を持つか)
- 条件分岐表示
- 配列操作(map / filter / 追加・更新)
といった要素が、より実務に近い形で絡み合ってきます。
「小さく作って、少しずつ育てる」感覚
大事なのは、いきなり完璧な TODO アプリを作ろうとしないことです。
- まずは「追加だけ」
- 次に「削除を付ける」
- その次に「完了フラグ」
- さらに「保存」
というように、一つずつ機能を足していくと、
そのたびに「状態をどう増やすか」「どこに持つか」を考える練習になります。
まとめと次の一歩
ここまでの TODO アプリで押さえたポイントは、実務でもそのまま使える基礎です。
- 入力欄は state で管理し、
valueとonChangeをセットで書く - 追加処理は「フォーム送信+配列の新しい要素を作る+配列をコピーして追加」で実装する
- 削除処理は「残したいものだけを filter で残す」という発想で書く
- state の種類(入力値・メインデータ・内部カウンタ)を意識して整理する

