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 はあとから通信を取り消すためのリモコンです。signal を fetch に渡しておくと、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 件だけです。
fetch も AbortController も 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>
);
}