本文へスキップ
TODO リストを作る

Part 11 · まとめて作る

59 / 64

TODO リストを作る

型・部品・props・CRUD を 1 つの画面で通す

ここまで、部品・props・型・state・リストと key を ばらばらに見てきました。

この章では、それを1 つの画面にまとめます。 題材は TODO リスト。作る・読む・変える・消すが全部入っていて、 しかも短いからです。

新しい道具は出てきません。すでに習ったものだけで、実物が組み上がることを見てください。

まず、扱うデータの形を決める

画面から書き始めたくなりますが、先に型です。 何を持つのかが決まっていないと、部品の作りようがありません。

export type Todo = {
  id: number;    // 見分けるための番号。key にも使う
  text: string;  // やることの内容
  done: boolean; // 済んだかどうか
};

3 つだけです。この 3 つが決まれば、あとは全部これに従って書けます。

Filter の型も同じファイルに置いてあります。 Part 4 でやったとおり、とりうる値を並べて書けば、それ以外は書けなくなります

types.ts

/*
  この画面で扱うデータの形。
  ここを最初に決めておくと、あとの部品が全部これに従って書ける。
*/

export type Todo = {
  /** 見分けるための番号。key にも使う */
  id: number;
  /** やることの内容 */
  text: string;
  /** 済んだかどうか */
  done: boolean;
};

/** 一覧の絞り込み。とりうる値をここで決めきる */
export type Filter = "all" | "active" | "done";

export const filterLabels: Record<Filter, string> = {
  all: "すべて",
  active: "未完了",
  done: "完了",
};

/** 絞り込みは「持たずに計算する」。だから関数にしておく */
export const filterTodos = (todos: Todo[], filter: Filter) => {
  if (filter === "active") return todos.filter((todo) => !todo.done);
  if (filter === "done") return todos.filter((todo) => todo.done);
  return todos;
};

できあがったもの

先に完成品を触ってください。そのあと中を開けます。

TODO リスト直した例

追加・完了・編集・削除・絞り込みが動く

  • 牛乳を買う
  • React の教材を読む
  • 歯医者を予約する

残り 2

新しい道具は 1 つも使っていません。useStatemapfilter、 それに props です。

todo-app.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { TodoItem } from "./todo-item";
import { type Filter, type Todo, filterLabels, filterTodos } from "./types";

/*
  データを持っているのはここだけ。
  子は「押された」と伝えてくるだけで、実際に変えるのは全部この中。
*/

let nextId = 4;

export function TodoApp() {
  useTrackDemoRender();

  const [todos, setTodos] = useState<Todo[]>([
    { id: 1, text: "牛乳を買う", done: false },
    { id: 2, text: "React の教材を読む", done: true },
    { id: 3, text: "歯医者を予約する", done: false },
  ]);
  const [draft, setDraft] = useState("");
  const [filter, setFilter] = useState<Filter>("all");

  // C: 追加する。元の配列は触らず、新しい配列を作る
  const add = () => {
    const text = draft.trim();
    if (!text) return;

    setTodos((current) => [...current, { id: nextId++, text, done: false }]);
    setDraft("");
  };

  // U: 済み / 未済みを切り替える。該当の 1 件だけ差し替える
  const toggle = (id: number) => {
    setTodos((current) =>
      current.map((todo) =>
        todo.id === id ? { ...todo, done: !todo.done } : todo,
      ),
    );
  };

  // U: 文言を書き換える
  const edit = (id: number, text: string) => {
    setTodos((current) =>
      current.map((todo) => (todo.id === id ? { ...todo, text } : todo)),
    );
  };

  // D: 消す。残すものだけを集める
  const remove = (id: number) => {
    setTodos((current) => current.filter((todo) => todo.id !== id));
  };

  // 絞り込んだ結果と残り件数は state にしない。毎回そこから計算する
  const shown = filterTodos(todos, filter);
  const remaining = todos.filter((todo) => !todo.done).length;

  return (
    <div className="flex flex-col gap-4">
      <div className="flex gap-2">
        <Input
          placeholder="やることを書く"
          aria-label="やることを書く"
          value={draft}
          onChange={(event) => setDraft(event.target.value)}
          onKeyDown={(event) => event.key === "Enter" && add()}
        />
        <Button size="sm" onClick={add} disabled={draft.trim() === ""}>
          追加
        </Button>
      </div>

      <div className="flex gap-2">
        {(Object.keys(filterLabels) as Filter[]).map((value) => (
          <Button
            key={value}
            size="sm"
            variant={filter === value ? "default" : "outline"}
            onClick={() => setFilter(value)}
          >
            {filterLabels[value]}
          </Button>
        ))}
      </div>

      {/* R: 並べる。key は index ではなく id */}
      <ul className="flex flex-col gap-2">
        {shown.map((todo) => (
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={toggle}
            onDelete={remove}
            onEdit={edit}
          />
        ))}
      </ul>

      {shown.length === 0 && (
        <p className="text-muted-foreground">この条件に合うものはありません</p>
      )}

      <p className="text-sm text-muted-foreground">残り {remaining} 件</p>
    </div>
  );
}

データはどこに置いたか

todos を持っているのはTodoApp だけです。 1 件ぶんを描く TodoItem は持っていません。

Part 4 の「state のリフトアップ」でやった判断です。複数の場所から使うものは、共通の親に置く。 追加も削除も絞り込みも、全部この 1 つの配列を見ています。

todo-app.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { TodoItem } from "./todo-item";
import { type Filter, type Todo, filterLabels, filterTodos } from "./types";

/*
  データを持っているのはここだけ。
  子は「押された」と伝えてくるだけで、実際に変えるのは全部この中。
*/

let nextId = 4;

export function TodoApp() {
  useTrackDemoRender();

  const [todos, setTodos] = useState<Todo[]>([
    { id: 1, text: "牛乳を買う", done: false },
    { id: 2, text: "React の教材を読む", done: true },
    { id: 3, text: "歯医者を予約する", done: false },
  ]);
  const [draft, setDraft] = useState("");
  const [filter, setFilter] = useState<Filter>("all");

  // C: 追加する。元の配列は触らず、新しい配列を作る
  const add = () => {
    const text = draft.trim();
    if (!text) return;

    setTodos((current) => [...current, { id: nextId++, text, done: false }]);
    setDraft("");
  };

  // U: 済み / 未済みを切り替える。該当の 1 件だけ差し替える
  const toggle = (id: number) => {
    setTodos((current) =>
      current.map((todo) =>
        todo.id === id ? { ...todo, done: !todo.done } : todo,
      ),
    );
  };

  // U: 文言を書き換える
  const edit = (id: number, text: string) => {
    setTodos((current) =>
      current.map((todo) => (todo.id === id ? { ...todo, text } : todo)),
    );
  };

  // D: 消す。残すものだけを集める
  const remove = (id: number) => {
    setTodos((current) => current.filter((todo) => todo.id !== id));
  };

  // 絞り込んだ結果と残り件数は state にしない。毎回そこから計算する
  const shown = filterTodos(todos, filter);
  const remaining = todos.filter((todo) => !todo.done).length;

  return (
    <div className="flex flex-col gap-4">
      <div className="flex gap-2">
        <Input
          placeholder="やることを書く"
          aria-label="やることを書く"
          value={draft}
          onChange={(event) => setDraft(event.target.value)}
          onKeyDown={(event) => event.key === "Enter" && add()}
        />
        <Button size="sm" onClick={add} disabled={draft.trim() === ""}>
          追加
        </Button>
      </div>

      <div className="flex gap-2">
        {(Object.keys(filterLabels) as Filter[]).map((value) => (
          <Button
            key={value}
            size="sm"
            variant={filter === value ? "default" : "outline"}
            onClick={() => setFilter(value)}
          >
            {filterLabels[value]}
          </Button>
        ))}
      </div>

      {/* R: 並べる。key は index ではなく id */}
      <ul className="flex flex-col gap-2">
        {shown.map((todo) => (
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={toggle}
            onDelete={remove}
            onEdit={edit}
          />
        ))}
      </ul>

      {shown.length === 0 && (
        <p className="text-muted-foreground">この条件に合うものはありません</p>
      )}

      <p className="text-sm text-muted-foreground">残り {remaining} 件</p>
    </div>
  );
}

作る・変える・消すを、全部同じ形で書く

ここがこの章の芯です。元の配列には触れません。毎回新しく作ります。

// 作る … 後ろに足した新しい配列
setTodos((current) => [...current, newTodo]);

// 変える … 該当の 1 件だけ差し替えた新しい配列
setTodos((current) =>
  current.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
);

// 消す … 残すものだけ集めた新しい配列
setTodos((current) => current.filter((t) => t.id !== id));

3 つとも「新しい配列を作って渡す」形です。Part 4 の「オブジェクトと配列の更新」でやったとおり、 React は箱が別物になったかだけを見ています。

todo-app.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { TodoItem } from "./todo-item";
import { type Filter, type Todo, filterLabels, filterTodos } from "./types";

/*
  データを持っているのはここだけ。
  子は「押された」と伝えてくるだけで、実際に変えるのは全部この中。
*/

let nextId = 4;

export function TodoApp() {
  useTrackDemoRender();

  const [todos, setTodos] = useState<Todo[]>([
    { id: 1, text: "牛乳を買う", done: false },
    { id: 2, text: "React の教材を読む", done: true },
    { id: 3, text: "歯医者を予約する", done: false },
  ]);
  const [draft, setDraft] = useState("");
  const [filter, setFilter] = useState<Filter>("all");

  // C: 追加する。元の配列は触らず、新しい配列を作る
  const add = () => {
    const text = draft.trim();
    if (!text) return;

    setTodos((current) => [...current, { id: nextId++, text, done: false }]);
    setDraft("");
  };

  // U: 済み / 未済みを切り替える。該当の 1 件だけ差し替える
  const toggle = (id: number) => {
    setTodos((current) =>
      current.map((todo) =>
        todo.id === id ? { ...todo, done: !todo.done } : todo,
      ),
    );
  };

  // U: 文言を書き換える
  const edit = (id: number, text: string) => {
    setTodos((current) =>
      current.map((todo) => (todo.id === id ? { ...todo, text } : todo)),
    );
  };

  // D: 消す。残すものだけを集める
  const remove = (id: number) => {
    setTodos((current) => current.filter((todo) => todo.id !== id));
  };

  // 絞り込んだ結果と残り件数は state にしない。毎回そこから計算する
  const shown = filterTodos(todos, filter);
  const remaining = todos.filter((todo) => !todo.done).length;

  return (
    <div className="flex flex-col gap-4">
      <div className="flex gap-2">
        <Input
          placeholder="やることを書く"
          aria-label="やることを書く"
          value={draft}
          onChange={(event) => setDraft(event.target.value)}
          onKeyDown={(event) => event.key === "Enter" && add()}
        />
        <Button size="sm" onClick={add} disabled={draft.trim() === ""}>
          追加
        </Button>
      </div>

      <div className="flex gap-2">
        {(Object.keys(filterLabels) as Filter[]).map((value) => (
          <Button
            key={value}
            size="sm"
            variant={filter === value ? "default" : "outline"}
            onClick={() => setFilter(value)}
          >
            {filterLabels[value]}
          </Button>
        ))}
      </div>

      {/* R: 並べる。key は index ではなく id */}
      <ul className="flex flex-col gap-2">
        {shown.map((todo) => (
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={toggle}
            onDelete={remove}
            onEdit={edit}
          />
        ))}
      </ul>

      {shown.length === 0 && (
        <p className="text-muted-foreground">この条件に合うものはありません</p>
      )}

      <p className="text-sm text-muted-foreground">残り {remaining} 件</p>
    </div>
  );
}

絞り込みと件数は、持たない

「絞り込んだ結果」と「残り件数」を state にしたくなります。しません。

const shown = filterTodos(todos, filter);
const remaining = todos.filter((todo) => !todo.done).length;

どちらも todosfilter から毎回計算できます。 state にすると、追加したとき・消したとき・切り替えたときに全部更新して回る必要が出てきます。 1 か所忘れれば、そこがずれます。

Part 4 の「state は最小限にする」が、実物ではこう効きます。

types.ts

/*
  この画面で扱うデータの形。
  ここを最初に決めておくと、あとの部品が全部これに従って書ける。
*/

export type Todo = {
  /** 見分けるための番号。key にも使う */
  id: number;
  /** やることの内容 */
  text: string;
  /** 済んだかどうか */
  done: boolean;
};

/** 一覧の絞り込み。とりうる値をここで決めきる */
export type Filter = "all" | "active" | "done";

export const filterLabels: Record<Filter, string> = {
  all: "すべて",
  active: "未完了",
  done: "完了",
};

/** 絞り込みは「持たずに計算する」。だから関数にしておく */
export const filterTodos = (todos: Todo[], filter: Filter) => {
  if (filter === "active") return todos.filter((todo) => !todo.done);
  if (filter === "done") return todos.filter((todo) => todo.done);
  return todos;
};

部品の分け方と、props の形

TodoItem が受け取るのは 4 つです。

type Props = {
  todo: Todo;
  onToggle: (id: number) => void;
  onDelete: (id: number) => void;
  onEdit: (id: number, text: string) => void;
};

下に降りるのはデータ 1 件、上に返すのは「何が起きたか」。Part 2 の「props の渡し方いろいろ」でやった形が、そのまま出ています。

keytodo.id を使っているのも、 Part 3 でやったとおりです。並び替えや削除で行がずれるのを防ぎます。 編集中の入力欄を持っているので、ここは実害に直結します。

「実害」と言われてもぴんと来ないと思うので、index にしたものを置いておきます。

key に index を使った版うまくいかない例

一番上を書き換えてから、その行を消す

一番上の「牛乳を買う」を書き換えてから、その行を消してください。

消したはずの書きかけの文字が、次の行に残ります。 消えたのは「牛乳を買う」の行なのに、 打っていた文字だけが「本を返す」の行に移っています。

React から見ると、0 番の行は消えていません。0 番の中身が「本を返す」に変わっただけです。 だから同じ部品を使い回し、 その部品が持っていた編集中の文字もそのまま残ります。

todo-item.tsx

"use client";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import type { Todo } from "./types";

/*
  1 件ぶんの表示と、編集の見た目を持つ部品。
  「どのデータを消すか」は決めない。押されたことを伝えるだけ。
*/

type Props = {
  todo: Todo;
  onToggle: (id: number) => void;
  onDelete: (id: number) => void;
  onEdit: (id: number, text: string) => void;
};

export function TodoItem({ todo, onToggle, onDelete, onEdit }: Props) {
  // 「編集中かどうか」は、この 1 件の中だけの話。だからここで持つ
  const [isEditing, setIsEditing] = useState(false);
  const [draft, setDraft] = useState(todo.text);

  const save = () => {
    const text = draft.trim();
    if (text) onEdit(todo.id, text);
    setIsEditing(false);
  };

  if (isEditing) {
    return (
      <li className="flex items-center gap-2 rounded-md border p-2">
        <Input
          value={draft}
          onChange={(event) => setDraft(event.target.value)}
          onKeyDown={(event) => {
            if (event.key === "Enter") save();
            if (event.key === "Escape") setIsEditing(false);
          }}
        />
        <Button size="sm" onClick={save}>
          保存
        </Button>
      </li>
    );
  }

  return (
    <li className="flex items-center gap-2 rounded-md border p-2">
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => onToggle(todo.id)}
        aria-label={`${todo.text} を完了にする`}
      />

      <span className={todo.done ? "flex-1 text-muted-foreground line-through" : "flex-1"}>
        {todo.text}
      </span>

      <Button
        size="sm"
        variant="outline"
        onClick={() => {
          setDraft(todo.text);
          setIsEditing(true);
        }}
      >
        編集
      </Button>
      <Button size="sm" variant="outline" onClick={() => onDelete(todo.id)}>
        削除
      </Button>
    </li>
  );
}

やりがちな書き方を、3 つ同時に入れてみる

下のデモには、よくある間違いが 3 つ入っています。まず追加してから、チェックを押してみてください。

ときどきしか動かない TODOうまくいかない例

追加してから、チェックを押してみる

  • 牛乳を買う
  • 歯医者を予約する

残り 2

追加は、できてしまいます。ところがチェックを押しても、打ち消し線が付きません。 しかも残り件数は、追加すると増えるのに、チェックしても減りません。

入っている間違いは 3 つです。

  • push で元の配列に足している … 箱は同じままなので、React は変化に気づけない
  • 中身のオブジェクトを直接書き換えている … 同じ理由。target.done = ... では別物になりません
  • 残り件数を state に持っている … 追加では増やしているのに、チェックでは減らし忘れています。実際にずれています

broken-todo.tsx

"use client";

/*
  よくある書き方を 3 つ、わざと同時に入れてある。

  注意: この 3 つは lint に引っかからない。型も通るしビルドも通る。
  しかも「追加」は動いてしまう(直後の setDraft が描き直しを起こすため)。
  「チェック」だけが動かない。この一貫しなさが、いちばん厄介なところ。
*/

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import type { Todo } from "./types";

export function BrokenTodo() {
  useTrackDemoRender();

  const [todos, setTodos] = useState<Todo[]>([
    { id: 1, text: "牛乳を買う", done: false },
    { id: 2, text: "歯医者を予約する", done: false },
  ]);
  const [draft, setDraft] = useState("");
  // ✕ 3. 残り件数を state に持ってしまっている(todos から計算できるのに)
  const [remaining, setRemaining] = useState(2);

  const add = () => {
    if (!draft.trim()) return;

    // ✕ 1. 元の配列に push している。React から見ると「同じ箱」のまま
    todos.push({ id: Date.now(), text: draft, done: false });
    setTodos(todos);
    setDraft("");
    setRemaining(remaining + 1);
  };

  const toggle = (id: number) => {
    // ✕ 2. 中身のオブジェクトを直接書き換えている
    const target = todos.find((todo) => todo.id === id);
    if (target) target.done = !target.done;
    setTodos(todos);
  };

  return (
    <div className="flex flex-col gap-3">
      <div className="flex gap-2">
        <Input
          placeholder="やることを書く"
          aria-label="やることを書く"
          value={draft}
          onChange={(event) => setDraft(event.target.value)}
        />
        <Button size="sm" onClick={add}>
          追加
        </Button>
      </div>

      <ul className="flex flex-col gap-2">
        {todos.map((todo) => (
          <li key={todo.id} className="flex items-center gap-2 rounded-md border p-2">
            <input
              type="checkbox"
              checked={todo.done}
              onChange={() => toggle(todo.id)}
              aria-label={`${todo.text} を完了にする`}
            />
            <span className={todo.done ? "line-through" : ""}>{todo.text}</span>
          </li>
        ))}
      </ul>

      <p className="text-sm text-muted-foreground">残り {remaining} 件</p>
    </div>
  );
}

理解できたか確かめる

確認クイズ

TODO を 1 件消すとき、正しい書き方は?

確認クイズ

「編集中かどうか」を TodoItem の中に置いたのはなぜ?

確認クイズ

残り件数を state にしないのはなぜ?

todo-app.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { TodoItem } from "./todo-item";
import { type Filter, type Todo, filterLabels, filterTodos } from "./types";

/*
  データを持っているのはここだけ。
  子は「押された」と伝えてくるだけで、実際に変えるのは全部この中。
*/

let nextId = 4;

export function TodoApp() {
  useTrackDemoRender();

  const [todos, setTodos] = useState<Todo[]>([
    { id: 1, text: "牛乳を買う", done: false },
    { id: 2, text: "React の教材を読む", done: true },
    { id: 3, text: "歯医者を予約する", done: false },
  ]);
  const [draft, setDraft] = useState("");
  const [filter, setFilter] = useState<Filter>("all");

  // C: 追加する。元の配列は触らず、新しい配列を作る
  const add = () => {
    const text = draft.trim();
    if (!text) return;

    setTodos((current) => [...current, { id: nextId++, text, done: false }]);
    setDraft("");
  };

  // U: 済み / 未済みを切り替える。該当の 1 件だけ差し替える
  const toggle = (id: number) => {
    setTodos((current) =>
      current.map((todo) =>
        todo.id === id ? { ...todo, done: !todo.done } : todo,
      ),
    );
  };

  // U: 文言を書き換える
  const edit = (id: number, text: string) => {
    setTodos((current) =>
      current.map((todo) => (todo.id === id ? { ...todo, text } : todo)),
    );
  };

  // D: 消す。残すものだけを集める
  const remove = (id: number) => {
    setTodos((current) => current.filter((todo) => todo.id !== id));
  };

  // 絞り込んだ結果と残り件数は state にしない。毎回そこから計算する
  const shown = filterTodos(todos, filter);
  const remaining = todos.filter((todo) => !todo.done).length;

  return (
    <div className="flex flex-col gap-4">
      <div className="flex gap-2">
        <Input
          placeholder="やることを書く"
          aria-label="やることを書く"
          value={draft}
          onChange={(event) => setDraft(event.target.value)}
          onKeyDown={(event) => event.key === "Enter" && add()}
        />
        <Button size="sm" onClick={add} disabled={draft.trim() === ""}>
          追加
        </Button>
      </div>

      <div className="flex gap-2">
        {(Object.keys(filterLabels) as Filter[]).map((value) => (
          <Button
            key={value}
            size="sm"
            variant={filter === value ? "default" : "outline"}
            onClick={() => setFilter(value)}
          >
            {filterLabels[value]}
          </Button>
        ))}
      </div>

      {/* R: 並べる。key は index ではなく id */}
      <ul className="flex flex-col gap-2">
        {shown.map((todo) => (
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={toggle}
            onDelete={remove}
            onEdit={edit}
          />
        ))}
      </ul>

      {shown.length === 0 && (
        <p className="text-muted-foreground">この条件に合うものはありません</p>
      )}

      <p className="text-sm text-muted-foreground">残り {remaining} 件</p>
    </div>
  );
}

この章のまとめ

  • 画面より先にデータの形(型)を決める
  • 作る・変える・消すは、どれも新しい配列を作って渡す。 元のものは触らない(読むのは並べるだけなので、更新は要らない)
  • 共有するものは共通の親へ、 その場限りのものはその部品の中へ
  • 計算できるものは state にしない(絞り込み、件数)
  • 子はデータ 1 件と、起きたことを伝える関数を受け取る
  • 画面が変わらないときは「元のものを書き換えていないか」を疑う

todo-app.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { TodoItem } from "./todo-item";
import { type Filter, type Todo, filterLabels, filterTodos } from "./types";

/*
  データを持っているのはここだけ。
  子は「押された」と伝えてくるだけで、実際に変えるのは全部この中。
*/

let nextId = 4;

export function TodoApp() {
  useTrackDemoRender();

  const [todos, setTodos] = useState<Todo[]>([
    { id: 1, text: "牛乳を買う", done: false },
    { id: 2, text: "React の教材を読む", done: true },
    { id: 3, text: "歯医者を予約する", done: false },
  ]);
  const [draft, setDraft] = useState("");
  const [filter, setFilter] = useState<Filter>("all");

  // C: 追加する。元の配列は触らず、新しい配列を作る
  const add = () => {
    const text = draft.trim();
    if (!text) return;

    setTodos((current) => [...current, { id: nextId++, text, done: false }]);
    setDraft("");
  };

  // U: 済み / 未済みを切り替える。該当の 1 件だけ差し替える
  const toggle = (id: number) => {
    setTodos((current) =>
      current.map((todo) =>
        todo.id === id ? { ...todo, done: !todo.done } : todo,
      ),
    );
  };

  // U: 文言を書き換える
  const edit = (id: number, text: string) => {
    setTodos((current) =>
      current.map((todo) => (todo.id === id ? { ...todo, text } : todo)),
    );
  };

  // D: 消す。残すものだけを集める
  const remove = (id: number) => {
    setTodos((current) => current.filter((todo) => todo.id !== id));
  };

  // 絞り込んだ結果と残り件数は state にしない。毎回そこから計算する
  const shown = filterTodos(todos, filter);
  const remaining = todos.filter((todo) => !todo.done).length;

  return (
    <div className="flex flex-col gap-4">
      <div className="flex gap-2">
        <Input
          placeholder="やることを書く"
          aria-label="やることを書く"
          value={draft}
          onChange={(event) => setDraft(event.target.value)}
          onKeyDown={(event) => event.key === "Enter" && add()}
        />
        <Button size="sm" onClick={add} disabled={draft.trim() === ""}>
          追加
        </Button>
      </div>

      <div className="flex gap-2">
        {(Object.keys(filterLabels) as Filter[]).map((value) => (
          <Button
            key={value}
            size="sm"
            variant={filter === value ? "default" : "outline"}
            onClick={() => setFilter(value)}
          >
            {filterLabels[value]}
          </Button>
        ))}
      </div>

      {/* R: 並べる。key は index ではなく id */}
      <ul className="flex flex-col gap-2">
        {shown.map((todo) => (
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={toggle}
            onDelete={remove}
            onEdit={edit}
          />
        ))}
      </ul>

      {shown.length === 0 && (
        <p className="text-muted-foreground">この条件に合うものはありません</p>
      )}

      <p className="text-sm text-muted-foreground">残り {remaining} 件</p>
    </div>
  );
}