Next.jsで学ぶReact講座(完全初心者向け・30日) | 第4章:実用的React – ミニアプリ③

React Next.js
スポンサーリンク

ここで作る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>
TSX

handleAdd の中身を見てみましょう。

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 オブジェクトを作る(idtext を持つ)。
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.idhandleDelete に渡す
  • handleDelete の中で、その id を使って配列をフィルタする

という流れになっています。

「どの TODO を削除したいのか」を識別するために、
id を持たせておくことが重要です。
配列の index でも一応できますが、並び替えや挿入が入るとバグの元になるので、
基本的には「一意な id」を持たせるのが実務寄りです。


状態管理の総復習として見る

このTODOアプリに出てくる「状態」を整理する

この小さなアプリの中に、実は状態管理のエッセンスが詰まっています。

  • text … フォームの入力値(ユーザーの一時的な入力)
  • todos … アプリのメインデータ(TODO の一覧)
  • nextId … 新しい TODO に割り当てるためのカウンタ

それぞれ役割が違います。

text は UI に強く結びついた「一時的な状態」。
todos は画面の主役となる「リストデータ」。
nextId は内部的な管理用の状態(ユーザーには見えない)。

このように、「状態の種類」を意識して分けて考えると、
どこに何を置くべきかが見えやすくなります。

state の持ち場所はどこが正しいか

今回の例では、すべての state を TodoPage コンポーネントに置いています。
理由はシンプルで、

  • 入力欄も
  • TODO一覧も
  • 追加・削除のロジックも

すべてこのページの中で完結しているからです。

もしこれをさらに分割するなら、

  • 入力フォーム部分を TodoForm コンポーネントに切り出す
  • TODO一覧部分を TodoList コンポーネントに切り出す

という形になりますが、その場合でも

  • todossetTodos(あるいは onAdd, onDelete)は親が持つ
  • 子は「表示」と「イベントのきっかけ」だけを担当する

という構造にするのが基本です。


実務レベルに近づくための視点

「ハッピーケースだけで終わらせない」

今回の TODO アプリは、まだかなりシンプルです。
実務に近づけるなら、例えばこんな要素を足していきます。

  • ローカルストレージに保存して、リロードしても TODO が残る
  • TODO に「完了フラグ」を付けて、完了・未完了を切り替えられる
  • フィルタ(すべて / 未完了 / 完了)を付ける
  • 入力チェックをもう少し厳密にする(文字数制限など)

これらを足していくと、

  • useEffect(初回読み込み・保存)
  • 状態設計(どこに何を持つか)
  • 条件分岐表示
  • 配列操作(map / filter / 追加・更新)

といった要素が、より実務に近い形で絡み合ってきます。

「小さく作って、少しずつ育てる」感覚

大事なのは、いきなり完璧な TODO アプリを作ろうとしないことです。

  • まずは「追加だけ」
  • 次に「削除を付ける」
  • その次に「完了フラグ」
  • さらに「保存」

というように、一つずつ機能を足していくと、
そのたびに「状態をどう増やすか」「どこに持つか」を考える練習になります。


まとめと次の一歩

ここまでの TODO アプリで押さえたポイントは、実務でもそのまま使える基礎です。

  • 入力欄は state で管理し、valueonChange をセットで書く
  • 追加処理は「フォーム送信+配列の新しい要素を作る+配列をコピーして追加」で実装する
  • 削除処理は「残したいものだけを filter で残す」という発想で書く
  • state の種類(入力値・メインデータ・内部カウンタ)を意識して整理する

タイトルとURLをコピーしました