本文へスキップ
次にやること

Part 11 · まとめて作る

64 / 64

次にやること

覚えることは 5 つだけ。次に何を作るか、詰まったらどうするか

おつかれさまでした。ここまでで一通りです。

最後に、これから何をすればいいかと、詰まったときにどうするかを書いておきます。

結局、覚えることは少ない

59 章ありましたが、芯は 5 つだけです。 これだけ持って帰れば足ります。

  1. 画面は状態から決まる。 画面を書き換えるのではなく、状態を書き換える
  2. state は最小限に、使う場所のいちばん近くへ。 計算できるものは持たない
  3. 元のものを書き換えず、新しく作る。 画面が変わらないときは、まずここを疑う
  4. 値は下へ、知らせは上へ。 子は「起きたこと」を伝えるだけ
  5. 持ち主が自分でないデータは、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>
  );
}

最後に、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>
  );
}