本文へスキップ
API を使う画面を作る

Part 11 · まとめて作る

61 / 64

API を使う画面を作る

本物の API を日本語で検索する。通信の 3 つの厄介ごとを潰す

仕事で React を書くとき、ほぼ必ず外の API からデータを取ってきます。 そして通信は、ここまでのどの話よりも思いどおりにならないものです。

時間がかかる。失敗する。順番が入れ替わる。この 3 つを、実物で 1 つずつ潰します。

題材はポケモンを日本語で検索する画面。 本物の PokeAPI を使います。

まず、素直に書いてみる

Part 6 でやったとおり、useEffect の中でfetch する。それだけです。

打つたびに問い合わせるうまくいかない例

「ピカチュウ」とゆっくり打って、回数を見る

問い合わせた回数: 0

    動きます。ですが「ピカチュウ」と打つと、問い合わせが 5 回飛びます

    実際に困るのは、次の 3 つです。

    • むだが多い 途中の「ピ」「ピカ」の結果は誰も見ていません
    • 順番が入れ替わる 「ピ」の結果が「ピカチュウ」より遅れて届くと、古い結果で上書きされます
    • 失敗したときに何も起きない 通信が切れても、画面は前の結果のままです

    2 つめが厄介です。たまにしか起きず、再現できません。「たまに検索結果がおかしい」という報告になります。

    naive-search.tsx

    "use client";
    
    import { useTrackDemoRender } from "@/components/lesson/demo-card";
    import { Input } from "@/components/ui/input";
    import { useEffect, useState } from "react";
    import { PokemonCard } from "./pokemon-card";
    import type { Pokemon } from "./types";
    
    /*
      素直に書いた検索。動きはします。
      ですが、通信でよく起きる 3 つの問題がそのまま出ます。
    
      下の lint 抑制は「取ってきた結果を state に置く」ため。
      外から返ってきた値の置き場所は state しかないので、ここは例外にあたる。
    */
    /* eslint-disable react-hooks/set-state-in-effect */
    
    export function NaiveSearch() {
      useTrackDemoRender();
    
      const [keyword, setKeyword] = useState("");
      const [results, setResults] = useState<Pokemon[]>([]);
      const [requestCount, setRequestCount] = useState(0);
    
      useEffect(() => {
        if (!keyword) {
          setResults([]);
          return;
        }
    
        setRequestCount((count) => count + 1);
    
        // ✕ 打つたびに飛ぶ。前の問い合わせも取り消していない
        fetch(`/api/pokemon?q=${encodeURIComponent(keyword)}`)
          .then((res) => res.json())
          .then((data) => setResults(data.results));
      }, [keyword]);
    
      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>
    
          {/* 見た目の部品は ✅ 版と同じものを使う。違うのは取ってくる方だけ */}
          <ul className="grid gap-2 sm:grid-cols-2">
            {results.map((pokemon) => (
              <PokemonCard key={pokemon.id} pokemon={pokemon} />
            ))}
          </ul>
        </div>
      );
    }

    なぜ、いったん自分のサーバーを通すのか

    このデモが叩いているのは PokeAPI ではなく、この教材自身の /api/pokemon です。 その中から PokeAPI を呼んでいます。

    遠回りに見えますが、実務ではこちらが普通です。

    そして画面側は、自分が決めた形だけを知っていれば済みます

    export type Pokemon = {
      id: number;
      name: string;          // 日本語名
      imageUrl: string | null;
      types: string[];       // 日本語のタイプ名
      height: number;        // cm
      weight: number;        // kg
    };

    向こうの都合で JSON の形が変わっても、直すのはサーバー側の 1 か所だけです。 画面の部品には手を触れずに済みます。

    types.ts

    /*
      画面が受け取るデータの形。
    
      PokeAPI が返す JSON は項目が数百ある。
      そのまま持ち回らず、こちらで「要るものだけ」の形を決めておく。
      こうしておくと、向こうの形が変わってもここだけ直せば済む。
    */
    
    export type Pokemon = {
      id: number;
      /** 日本語名 */
      name: string;
      imageUrl: string | null;
      /** 日本語のタイプ名 */
      types: string[];
      /** cm */
      height: number;
      /** kg */
      weight: number;
    };

    1. 回数を減らす

    const [query] = useDebounce(keyword.trim(), 400);

    Part 10 でやった debounce です。入力欄はすぐ反応し、問い合わせだけが遅れて追いつきます

    そして見張る先を keyword ではなく query に変えます。変更はそれだけです。

    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>
      );
    }

    2. 古い問い合わせを取り消す

    debounce で回数は減りますが、順番の入れ替わりは消えません。 間をあけて 2 回打てば、やはり 2 回飛びます。

    ここで Part 6 のクリーンアップが効いてきます。

    const controller = new AbortController();
    
    fetch(url, { signal: controller.signal });
    
    // 次の入力が来たら、走っている問い合わせを取り消す
    return () => controller.abort();

    AbortControllerあとから通信を取り消すためのリモコンです。signalfetch に渡しておくと、abort() でその通信を打ち切れます。

    そして取り消しは effect の後片付けに書きますquery が変わるたびに、React が前回の後片付けを呼んでくれる。つまり、新しい問い合わせが始まる前に、古いほうが必ず止まります。

    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>
      );
    }

    3. 途中と失敗を、表示に出す

    type Status = "idle" | "loading" | "done" | "error";

    Part 4 でやった形です。真偽値を並べず、1 つにまとめます。通信は必ず途中があり、必ず失敗しうるので、 ここが効きます。

    日本語でポケモンを探す直した例

    「ピ」「リザ」「ミュウ」などで探せます

    問い合わせた回数: 0

      試してみてください。

      • 打っている間は飛びません。手を止めて 400ms で探しにいきます
      • 探している間は「探しています…」が出ます
      • 見つからないときは、空欄ではなくその旨が出ます
      • 連打しても結果が入れ替わりません。 古いほうは取り消されているからです

      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>
        );
      }

      見た目の部品は、通信を知らない

      1 匹ぶんを描く PokemonCard が受け取るのは、Pokemon 1 件だけです。

      fetchAbortController Status も出てきません。 TODO の章の TodoItem とまったく同じ形です。

      pokemon-card.tsx

      import type { Pokemon } from "./types";
      
      /*
        1 匹ぶんの見た目だけを持つ部品。
        通信のことも、検索のことも知らない。
      */
      
      export function PokemonCard({ pokemon }: { pokemon: Pokemon }) {
        return (
          <li className="flex items-center gap-3 rounded-md border p-3">
            {pokemon.imageUrl && (
              // 外部の画像なので、next/image ではなく素の img を使っている
              // eslint-disable-next-line @next/next/no-img-element
              <img
                src={pokemon.imageUrl}
                alt={pokemon.name}
                width={80}
                height={80}
                className="size-20 shrink-0"
                loading="lazy"
              />
            )}
      
            <div className="min-w-0">
              <p className="font-semibold">
                {pokemon.name}
                <span className="ml-2 font-mono text-xs text-muted-foreground">
                  No.{pokemon.id}
                </span>
              </p>
              <p className="text-sm text-muted-foreground">
                {pokemon.types.join(" / ")}
              </p>
              <p className="text-xs text-muted-foreground">
                {pokemon.height}cm / {pokemon.weight}kg
              </p>
            </div>
          </li>
        );
      }

      ここまで書いて、はじめて SWR の意味が分かる

      この章で自分で書いたのは、次の 4 つです。

      • 読み込み中かどうかの管理
      • 失敗したときの扱い
      • 古い問い合わせの取り消し
      • 問い合わせの間引き

      Part 9 の SWR は、このうち上の 3 つを最初から持っています。 「1 行で state 3 つぶん」と書いたのは、こういうことでした。

      間引きだけは、SWR にもありません。打つたびに呼ぶかどうかは使う側の判断なので、 そこは自分で useDebounce を足します (この章のデモもそうしています)。

      先に自分で書いたからこそ、何を肩代わりしてもらっているのかが分かります。道具から入ると、ここが分からないまま使うことになります。

      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>
        );
      }

      理解できたか確かめる

      確認クイズ

      debounce を入れても、順番の入れ替わりが残るのはなぜ?

      確認クイズ

      controller.abort() を effect の後片付けに書くのはなぜ?

      確認クイズ

      ブラウザから直接 PokeAPI を叩かず、自分のサーバーを通す理由として最も重要なのは?

      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>
        );
      }

      この章のまとめ

      • 通信は時間がかかり、失敗し、順番が入れ替わる。 3 つとも別々に手当てが要る
      • debounce で回数を減らす。 ただし順番の保証にはならない
      • AbortController で古い問い合わせを取り消す。 置き場所はeffect の後片付け
      • 取り消しは失敗ではない。AbortError は無視する
      • 状態はunion で 1 つにまとめる
      • 外の API は自分のサーバーで受けて、自分の型に整える
      • 取ってくる係と見せる係を分ける

      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>
        );
      }