こんにちは、かつコーチです。
前回はRedux・Zustand・Context APIの使い分けについて解説しました。
状態管理の仕組みが分かってきたところで、今回は「外部のAPIからデータを取得して画面に表示する」という、実務では必ず通る道を扱います。
fetchを使ったデータ取得は一見シンプルですが、ローディング状態やエラー処理まで含めるとつまずきポイントが意外と多い分野です。
一つずつ丁寧に見ていきましょう。
fetchでデータを取得する基本
useEffectと組み合わせた基本形
Reactコンポーネント内でAPIを呼び出す場合、レンダリングのたびに通信が走らないようにuseEffectと組み合わせるのが基本です。
import { useEffect, useState } from "react";
type User = {
id: number;
name: string;
email: string;
};
const UserList = () => {
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
const fetchUsers = async () => {
const res = await fetch("https://api.example.com/users");
const data: User[] = await res.json();
setUsers(data);
};
fetchUsers();
}, []);
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
};
export default UserList;
依存配列を空配列[]にすることで、コンポーネントが最初に表示されたタイミングでだけAPIを呼び出しています。
useEffectの中で直接async関数は書けないため、内部にfetchUsersという非同期関数を定義してから呼び出しているのがポイントです。
レスポンスのJSON変換とレンダリング
fetchが返すレスポンスはそのままでは使えず、.json()メソッドでJavaScriptのオブジェクトに変換する必要があります。
このとき.json()自体もPromiseを返すため、awaitをつけ忘れると中身が空のPromiseオブジェクトのままstateに入ってしまいます。
型をUser[]のようにきちんと指定しておくと、変換後のデータの形をエディタが教えてくれるので、うっかりミスに気づきやすくなります。
ローディング・エラー状態の管理
3つの状態を用意する
実務でAPI連携を行う場合、データそのものだけでなく「読み込み中かどうか」「エラーが起きたかどうか」もあわせて管理するのが定石です。
import { useEffect, useState } from "react";
type User = {
id: number;
name: string;
};
const UserList = () => {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchUsers = async () => {
try {
setIsLoading(true);
const res = await fetch("https://api.example.com/users");
if (!res.ok) {
throw new Error(`HTTPエラー: ${res.status}`);
}
const data: User[] = await res.json();
setUsers(data);
} catch (err) {
setError(err instanceof Error ? err.message : "不明なエラーです");
} finally {
setIsLoading(false);
}
};
fetchUsers();
}, []);
if (isLoading) return <p>読み込み中です...</p>;
if (error) return <p>エラーが発生しました: {error}</p>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
};
export default UserList;
try/catch/finallyで囲むことで、通信が成功したとき・失敗したとき・処理が終わったときのそれぞれで、適切に状態を更新できます。
つまずきやすい点:res.okのチェック漏れ
私が実際につまずいたのは、fetchはサーバーが404や500を返してもcatchブロックに入ってくれないという仕様でした。
fetchが失敗したと判定されるのは、ネットワーク自体が繋がらないなど通信そのものが成立しなかった場合だけです。
そのため、res.ok(ステータスコードが200番台かどうか)を自分でチェックして、必要ならthrowしてあげないと、エラーレスポンスの中身をそのまま「正常なデータ」として扱ってしまいます。
私はこれに気づかず、存在しないユーザーIDを指定したのに画面が真っ白(エラーの内容が空オブジェクトのまま表示されようとしていた)になり、原因究明に時間を溶かした経験があります。
上のコード例のようにif (!res.ok) { throw new Error(...) }を必ず入れる習慣をつけておきましょう。
よくあるつまずきポイント:依存配列の書き方ミス
無限ループが発生するケース
もう一つ実務でよく見かけるミスが、useEffectの依存配列の書き方です。
❌ Before:オブジェクトを依存配列に入れてしまう
import { useEffect, useState } from "react";
const SearchResult = ({ query }: { query: string }) => {
const [results, setResults] = useState<string[]>([]);
const params = { q: query, limit: 10 };
useEffect(() => {
fetch(`https://api.example.com/search?q=${params.q}`)
.then((res) => res.json())
.then((data) => setResults(data));
}, [params]); // paramsは毎回新しいオブジェクトとして生成される
return <p>{results.length}件見つかりました</p>;
};
paramsはレンダリングのたびに新しいオブジェクトとして作り直されるため、中身が同じでもuseEffectは「値が変わった」と判定し、毎回APIを呼び直してしまいます。
結果としてAPI呼び出しが延々と繰り返される、実質的な無限ループに近い状態になります。
✅ After:依存配列にはプリミティブな値を指定する
import { useEffect, useState } from "react";
const SearchResult = ({ query }: { query: string }) => {
const [results, setResults] = useState<string[]>([]);
useEffect(() => {
fetch(`https://api.example.com/search?q=${query}`)
.then((res) => res.json())
.then((data) => setResults(data));
}, [query]); // 文字列や数値なら値の比較で判定できる
return <p>{results.length}件見つかりました</p>;
};
依存配列には、オブジェクトや配列ではなく、文字列や数値といったプリミティブな値を指定するのが基本です。
どうしてもオブジェクトを依存させたい場合は、useMemoで値が変わらない限り同じ参照を保つようにする、という対処法もあります。
応用・一歩先の使い方
AbortControllerでリクエストをキャンセルする
検索フォームのようにユーザーの入力のたびにAPIを呼ぶ場合、前のリクエストが終わる前に次のリクエストが飛んでしまい、古いレスポンスが後から届いて画面がちらつくことがあります。
これを防ぐにはAbortControllerを使い、コンポーネントがアンマウントされたとき(または次のuseEffectが走る前)に前のリクエストをキャンセルします。
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const res = await fetch("https://api.example.com/search", {
signal: controller.signal,
});
const data = await res.json();
setResults(data);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
console.error(err);
}
};
fetchData();
return () => controller.abort(); // クリーンアップ関数でキャンセル
}, [query]);
useEffectの戻り値にクリーンアップ関数を書いておくと、依存配列の値が変わって次のuseEffectが実行される直前に、前のリクエストがキャンセルされます。
カスタムフックへの切り出し
同じようなfetch処理を複数のコンポーネントで書いていると、コードが重複してメンテナンス性が下がってきます。
import { useEffect, useState } from "react";
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
setIsLoading(true);
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTPエラー: ${res.status}`);
const json: T = await res.json();
setData(json);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
setError(err instanceof Error ? err.message : "不明なエラーです");
} finally {
setIsLoading(false);
}
})();
return () => controller.abort();
}, [url]);
return { data, isLoading, error };
}
こうしてロジックをカスタムフックにまとめておくと、const { data, isLoading, error } = useFetch<User[]>(url)のように、どのコンポーネントからも同じ書き方で呼び出せます。
ただ、キャッシュや再取得のタイミング管理まで自前で作り込もうとすると、コード量が一気に増えてしまうのも事実です。
次回は、こうした「取得・キャッシュ・再取得」の面倒な部分をまとめて解決してくれるライブラリ、React Query(TanStack Query)を紹介します。
まとめ
この記事のポイント
fetchはレスポンスをそのまま使えず、.json()で変換してから利用するisLoading・data・errorの3つの状態を管理するのが基本形fetchは404や500でもcatchに入らないため、res.okのチェックが必須useEffectの依存配列にオブジェクトを直接入れると無限ループの原因になるAbortControllerでリクエストのキャンセルができ、古いレスポンスによる表示崩れを防げる
次に読むべき記事
→ 次の記事:React Query(TanStack Query)でデータ取得を効率化する