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;
};できあがったもの
先に完成品を触ってください。そのあと中を開けます。
追加・完了・編集・削除・絞り込みが動く
- 牛乳を買う
- React の教材を読む
- 歯医者を予約する
残り 2 件
新しい道具は 1 つも使っていません。useState と map と filter、 それに 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;どちらも todos と filter から毎回計算できます。 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 の渡し方いろいろ」でやった形が、そのまま出ています。
key に todo.id を使っているのも、 Part 3 でやったとおりです。並び替えや削除で行がずれるのを防ぎます。 編集中の入力欄を持っているので、ここは実害に直結します。
「実害」と言われてもぴんと来ないと思うので、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 つ入っています。まず追加してから、チェックを押してみてください。
追加してから、チェックを押してみる
- 牛乳を買う
- 歯医者を予約する
残り 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>
);
}