Part 11 · まとめて作る
64 / 64 章
次にやること
覚えることは 5 つだけ。次に何を作るか、詰まったらどうするか
おつかれさまでした。ここまでで一通りです。
最後に、これから何をすればいいかと、詰まったときにどうするかを書いておきます。
結局、覚えることは少ない
59 章ありましたが、芯は 5 つだけです。 これだけ持って帰れば足ります。
- 画面は状態から決まる。 画面を書き換えるのではなく、状態を書き換える
- state は最小限に、使う場所のいちばん近くへ。 計算できるものは持たない
- 元のものを書き換えず、新しく作る。 画面が変わらないときは、まずここを疑う
- 値は下へ、知らせは上へ。 子は「起きたこと」を伝えるだけ
- 持ち主が自分でないデータは、state にしない。 サーバーのものは取り直せる写しとして扱う
残りは全部、この 5 つの言い換えか、この 5 つを守ったうえで速くするための道具でした。
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>
);
}次に作るもの
読むのはここまでにして、作ってください。手を動かさないと、この 5 つは身につきません。
いきなり大きなものを作らないでください。Part 11 で作ったものを、自分の題材で作り直すのが いちばん早いです。
- CRUD のあるもの … 買い物メモ、読んだ本の記録、練習メニュー。 なんでも構いませんが、自分が本当に使うものにしてください
- API を叩くもの … 好きなサービスの公開 API を 1 つ選んで、検索できる画面を作る
pokemon-search.tsx
"use client";
import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Input } from "@/components/ui/input";
import { useEffect, useState } from "react";
import { useDebounce } from "use-debounce";
import { PokemonCard } from "./pokemon-card";
import type { Pokemon } from "./types";
/*
この lint は「effect の中で setState するな」と言う。ふだんは正しい
(Part 6「その useEffect は要らない」でやったとおり)。
ただし外の世界から返ってきた値を受け取る場面は例外で、
取ってきた結果をどこかに置く手段が state しかない。
この章は、その例外をあえて手で書いてみる章。
*/
/* eslint-disable react-hooks/set-state-in-effect */
/** とりうる状態を並べる。真偽値を増やさない */
type Status = "idle" | "loading" | "done" | "error";
export function PokemonSearch() {
useTrackDemoRender();
const [keyword, setKeyword] = useState("");
// 打ち終わってから 400ms で、こちらが追いつく
const [query] = useDebounce(keyword.trim(), 400);
const [status, setStatus] = useState<Status>("idle");
const [results, setResults] = useState<Pokemon[]>([]);
const [message, setMessage] = useState("");
// ❌ の版と見比べるための回数。実装の本筋ではない
const [requestCount, setRequestCount] = useState(0);
useEffect(() => {
if (!query) {
setStatus("idle");
setResults([]);
return;
}
// この問い合わせを、あとから取り消すためのリモコン
const controller = new AbortController();
const search = async () => {
setStatus("loading");
setRequestCount((count) => count + 1);
try {
const response = await fetch(
`/api/pokemon?q=${encodeURIComponent(query)}`,
{ signal: controller.signal },
);
if (!response.ok) {
throw new Error(`サーバーが ${response.status} を返しました`);
}
const data = await response.json();
setResults(data.results);
setStatus("done");
} catch (error) {
// 自分で取り消したときは、失敗として扱わない
if (error instanceof DOMException && error.name === "AbortError") return;
setMessage(error instanceof Error ? error.message : "失敗しました");
setStatus("error");
}
};
search();
// 次の入力が来たら、走っている問い合わせを取り消す
return () => controller.abort();
}, [query]);
return (
<div className="flex flex-col gap-3">
<Input
placeholder="ポケモンの名前(例: ピ、リザ、ミュウ)"
aria-label="ポケモンの名前(例: ピ、リザ、ミュウ)"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<p className="text-sm text-muted-foreground">
問い合わせた回数: <strong>{requestCount}</strong>
</p>
{status === "loading" && (
<p className="text-sm text-muted-foreground">探しています…</p>
)}
{status === "error" && <p className="text-sm text-destructive">{message}</p>}
{status === "done" && results.length === 0 && (
<p className="text-sm text-muted-foreground">
「{query}」に当てはまる名前は見つかりませんでした
</p>
)}
<ul className="grid gap-2 sm:grid-cols-2">
{results.map((pokemon) => (
<PokemonCard key={pokemon.id} pokemon={pokemon} />
))}
</ul>
</div>
);
}この教材で扱わなかったこと
仕事で必要になるが、ここでは触れていないものを挙げておきます。いま覚える必要はありません。必要になってから、その順で調べてください。
| 分野 | 必要になる場面 |
|---|---|
| ページの切り替え | 画面が複数になったとき。Next.js ならファイルを置くだけ |
| テスト | 直すたびに他が壊れるようになったとき |
| エラー境界 | 一部の失敗で画面全体が落ちて困ったとき(Part 8 で触れました) |
| Jotai / Redux など | Zustand は Part 9 で扱いました。 ほかのものは、それで足りないと実際に困ったとき |
| サーバーへの書き込み | Next.js の Server Actions。フォームの送信を サーバー側の関数に直接つなぐ書き方 |
| アクセシビリティ | キーボードだけで操作できるか、読み上げに乗るか。 この教材のデモにも入れてありますが、説明はしていません |
| スタイリングの設計 | 画面が増えて、見た目の指定が散らかってきたとき |
最後の行が大事です。困る前に入れないでください。Part 10 で書いたとおり、道具には値段があります。
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>
);
}調べ方
分からないことは、これからいくらでも出てきます。調べ方だけ持っていれば大丈夫です。
- 公式ドキュメントを最初に見る。ja.react.dev に日本語版があります。この教材の説明も、ほとんどここが元です
- エラーメッセージをそのまま検索する。 訳したり要約したりせず、英語のまま貼る
- 記事の日付を見る。 React は書き方が変わってきました。
.ProviderやforwardRefが出てくる記事は、 少し古い可能性があります
pokemon-search.tsx
"use client";
import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Input } from "@/components/ui/input";
import { useEffect, useState } from "react";
import { useDebounce } from "use-debounce";
import { PokemonCard } from "./pokemon-card";
import type { Pokemon } from "./types";
/*
この lint は「effect の中で setState するな」と言う。ふだんは正しい
(Part 6「その useEffect は要らない」でやったとおり)。
ただし外の世界から返ってきた値を受け取る場面は例外で、
取ってきた結果をどこかに置く手段が state しかない。
この章は、その例外をあえて手で書いてみる章。
*/
/* eslint-disable react-hooks/set-state-in-effect */
/** とりうる状態を並べる。真偽値を増やさない */
type Status = "idle" | "loading" | "done" | "error";
export function PokemonSearch() {
useTrackDemoRender();
const [keyword, setKeyword] = useState("");
// 打ち終わってから 400ms で、こちらが追いつく
const [query] = useDebounce(keyword.trim(), 400);
const [status, setStatus] = useState<Status>("idle");
const [results, setResults] = useState<Pokemon[]>([]);
const [message, setMessage] = useState("");
// ❌ の版と見比べるための回数。実装の本筋ではない
const [requestCount, setRequestCount] = useState(0);
useEffect(() => {
if (!query) {
setStatus("idle");
setResults([]);
return;
}
// この問い合わせを、あとから取り消すためのリモコン
const controller = new AbortController();
const search = async () => {
setStatus("loading");
setRequestCount((count) => count + 1);
try {
const response = await fetch(
`/api/pokemon?q=${encodeURIComponent(query)}`,
{ signal: controller.signal },
);
if (!response.ok) {
throw new Error(`サーバーが ${response.status} を返しました`);
}
const data = await response.json();
setResults(data.results);
setStatus("done");
} catch (error) {
// 自分で取り消したときは、失敗として扱わない
if (error instanceof DOMException && error.name === "AbortError") return;
setMessage(error instanceof Error ? error.message : "失敗しました");
setStatus("error");
}
};
search();
// 次の入力が来たら、走っている問い合わせを取り消す
return () => controller.abort();
}, [query]);
return (
<div className="flex flex-col gap-3">
<Input
placeholder="ポケモンの名前(例: ピ、リザ、ミュウ)"
aria-label="ポケモンの名前(例: ピ、リザ、ミュウ)"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<p className="text-sm text-muted-foreground">
問い合わせた回数: <strong>{requestCount}</strong>
</p>
{status === "loading" && (
<p className="text-sm text-muted-foreground">探しています…</p>
)}
{status === "error" && <p className="text-sm text-destructive">{message}</p>}
{status === "done" && results.length === 0 && (
<p className="text-sm text-muted-foreground">
「{query}」に当てはまる名前は見つかりませんでした
</p>
)}
<ul className="grid gap-2 sm:grid-cols-2">
{results.map((pokemon) => (
<PokemonCard key={pokemon.id} pokemon={pokemon} />
))}
</ul>
</div>
);
}最後に、5 つの芯を確かめる
確認クイズ
画面が更新されないとき、まず疑うのはどれ?
確認クイズ
サーバーから取ってきたデータを useState にコピーしていいのはどんなとき?
確認クイズ
新しい道具(ライブラリ)を入れるかどうかは、どう決める?
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>
);
}最後に
丸暗記した書き方は、状況が変われば使えなくなります。 仕組みが分かっていれば、初めて見るコードでも「たぶんこうだろう」と当たりが付けられます。 そこまで来れば、あとは自分で進めます。
ここから先は、作りながら覚えてください。
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>
);
}