ReactでAPIからデータを取得する基本形は、「useEffectの中でfetchを呼び、結果をuseStateに入れ、クリーンアップでリクエストを取り消す」です。 この形に「res.okの確認」と「AbortErrorの除外」を足すと、ローディング・エラー・空・データの4つの状態を正しく出し分けられるようになります。

この記事では、そのまま貼って動く完成コードに加えて、応答の遅いリクエストが後から届いて表示が入れ替わる「競合」を、遅延を付けたローカルAPIで実際に再現し、ignoreフラグ版とAbortController版で直したときのログを並べて掲載します。StrictModeで2回呼ばれる理由、useFetchへのカスタムフック化、React 19のuseとSuspenseを使う形、TanStack Queryを使う形まで、同じAPIで動かして比べます。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / React 19.3.0 / Vite 8.3.2 / TypeScript 7.0.2 / TanStack Query 5.104.1。ブラウザはPlaywright 1.56.0のChromium 141で動かし、コンソール出力と画面の文字を取得しています。

データ取得の早見表

やりたいこと 書き方 ポイント
マウント時に1回取得する useEffect(() => { ... }, []) 依存配列を空にする
引数(idや検索語)が変わるたびに取得する useEffect(() => { ... }, [id]) 使った値を依存配列に入れる
HTTPエラー(404・500)を拾う if (!res.ok) throw new Error(...) fetchは500でも例外を投げない
ローディング表示を出す loading のstateを持つ 取得開始でtrue、完了でfalse
0件のときの表示を出す data.length === 0 で分岐 エラーと空を分ける
応答の順番が入れ替わるのを防ぐ クリーンアップで ignore = true 届いた応答を捨てる
リクエストそのものを取り消す クリーンアップで controller.abort() サーバー側の処理も中断できる
取り消しをエラー表示にしない e.name === 'AbortError' なら何もしない 取り消しは想定どおりの動作
同じ処理を複数の画面で使う useFetch(url) にまとめる 状態と取り消しを1か所に
ローディングを親に任せる use(promise) と Suspense Promiseは使い回す
キャッシュや再取得まで任せる TanStack Queryの useQuery queryFn に渡される signal をfetchへ

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「ReactでAPIデータ取得 fetchとuseEffectの書き方と競合対策」

準備:遅延つきのローカルAPIとViteの設定

記事のコードはすべて、次のローカルAPIに対して動かしています。応答までの時間をわざと変えてあるのがポイントで、id=1は800ミリ秒、id=2とid=3は100ミリ秒で返ります。タスク一覧は、statusがopenなら3件、doneなら0件、brokenなら500エラーを返します。

mkdir fetch-demo && cd fetch-demo
npm init -y
npm pkg set type=module
npm i react react-dom @tanstack/react-query
npm i -D vite @vitejs/plugin-react typescript @types/react @types/react-dom
// server.mjs : 遅延つきのローカルAPI(node server.mjs で起動)
import http from 'node:http';

const TASKS = [
  { id: 1, title: '備品の発注' },
  { id: 2, title: '議事録の共有' },
  { id: 3, title: '記事の下書き' },
];
const ITEMS = {
  1: { id: 1, name: 'ノートPC', delay: 800 },
  2: { id: 2, name: 'モニター', delay: 100 },
  3: { id: 3, name: 'キーボード', delay: 100 },
};
const t0 = Date.now();
const log = (msg) => console.log(`${String(Date.now() - t0).padStart(5)}ms ${msg}`);

function send(res, status, body, delay) {
  setTimeout(() => {
    if (res.destroyed) return;
    res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
    res.end(JSON.stringify(body));
  }, delay);
}

http.createServer((req, res) => {
  const url = new URL(req.url, 'http://localhost');
  log(`受信 ${url.pathname}${url.search}`);
  res.on('close', () => {
    if (!res.writableEnded) log(`中断 ${url.pathname}${url.search}(クライアントが取り消し)`);
  });

  if (url.pathname === '/api/tasks') {
    const status = url.searchParams.get('status');
    if (status === 'done') return send(res, 200, [], 300);
    if (status === 'broken') return send(res, 500, { message: 'サーバー内部でエラーが起きました' }, 300);
    return send(res, 200, TASKS, 300);
  }
  const m = url.pathname.match(/^\/api\/items\/(\d+)$/);
  if (m && ITEMS[m[1]]) {
    const { delay, ...item } = ITEMS[m[1]];
    return send(res, 200, item, delay);
  }
  send(res, 404, { message: '見つかりません' }, 50);
}).listen(8860, () => console.log('API: http://localhost:8860'));

Viteの開発サーバーから /api をこのAPIへ中継します。こうしておくと、Reactのコードでは /api/items/1 のように相対パスで書けます。

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

const proxy = { '/api': 'http://localhost:8860' };

export default defineConfig({
  plugins: [react()],
  server: { port: 8861, proxy },
  preview: { port: 8862, proxy },
});
<!doctype html>
<html lang="ja">
  <head><meta charset="UTF-8" /><title>fetch demo</title></head>
  <body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>
{
  "compilerOptions": {
    "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler",
    "jsx": "react-jsx", "strict": true, "noEmit": true, "skipLibCheck": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"]
  },
  "include": ["src"]
}

起動はターミナルを2つ使います。

node server.mjs          # API(8860番)
npx vite                 # 開発サーバー(8861番)

4つの状態を表示する完成コード

データ取得の画面には、ローディング・エラー・空・データの4つの状態があります。まずは全部入りの完成形です。

import { useEffect, useState } from 'react';

type Task = { id: number; title: string };

export function TaskList({ status }: { status: string }) {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    async function load() {
      try {
        const res = await fetch(`/api/tasks?status=${status}`, {
          signal: controller.signal,
        });
        if (!res.ok) {
          throw new Error(`HTTP ${res.status} ${res.statusText}`);
        }
        const data: Task[] = await res.json();
        setTasks(data);
        setLoading(false);
      } catch (e) {
        if (e instanceof DOMException && e.name === 'AbortError') return;
        setError(e instanceof Error ? e.message : String(e));
        setLoading(false);
      }
    }
    load();

    return () => controller.abort();
  }, [status]);

  if (loading) return <p role="status">読み込み中…</p>;
  if (error) return <p role="alert">取得できませんでした({error})</p>;
  if (tasks.length === 0) return <p>タスクはありません。</p>;
  return (
    <ul>
      {tasks.map((t) => (
        <li key={t.id}>{t.title}</li>
      ))}
    </ul>
  );
}

ボタンでstatusを切り替え、ボタンを押した直後と0.7秒後の画面の文字を取り出した結果が次のとおりです。

open 直後 : 読み込み中…
open 完了 : 備品の発注 / 議事録の共有 / 記事の下書き
done 直後 : 読み込み中…
done 完了 : タスクはありません。
broken直後: 読み込み中…
broken完了: 取得できませんでした(HTTP 500 Internal Server Error)

状態の出し分けは「上から順に早期リターン」

4つの状態は、次の順番で判定すると取りこぼしがありません。

順番 判定 表示 補足
1 loading がtrue 読み込み中… 取得のたびに最初にtrueへ戻す
2 error がある 取得できませんでした HTTPエラーと通信エラーの両方が入る
3 tasks.length === 0 タスクはありません。 取得は成功、中身が0件
4 それ以外 一覧 key にはidを使う

エラーと空を同じ表示にまとめないことが大切です。「0件です」と「取得できませんでした」は、読む人が次に取る行動が違うからです。role="status" と role="alert" を付けておくと、読み上げソフトにも状態の変化が伝わります。

res.okを確認する理由

fetchは、サーバーが404や500を返しても例外を投げません。 例外になるのは、接続そのものができなかったときです。Node.js 22で同じAPIを呼んで確かめました。

const r = await fetch('http://localhost:8860/api/tasks?status=broken');
console.log('fetchは例外を投げずに完了: ok=%s status=%s', r.ok, r.status, JSON.stringify(await r.json()));
try {
  await fetch('http://localhost:8869/none'); // 何も待ち受けていないポート
} catch (e) {
  console.log('接続できないとき:', e.name, e.message);
}
fetchは例外を投げずに完了: ok=false status=500 {"message":"サーバー内部でエラーが起きました"}
接続できないとき: TypeError fetch failed

500のときも res.json() まで進めてしまうので、確認を入れないと、エラー内容のオブジェクトを「タスクの配列」として扱うことになります。if (!res.ok) throw new Error(...) を1行入れて、HTTPエラーをcatchへ流すのが定番の形です。

起きたこと fetchの結果 res.ok catchに入るか
200で成功 完了 true 入らない
404・500など 完了 false throw を書いたときだけ入る
接続できない 拒否(TypeError) ― 入る
abort() で取り消し 拒否(AbortError) ― 入る(除外する)

useEffectにasync関数を直接渡さない

useEffect に渡す関数の戻り値は「クリーンアップ関数か、何も返さないか」のどちらかです。async関数はPromiseを返すので、直接渡すと型チェックで止まります。次のコードを tsc --noEmit にかけたときのメッセージです。

useEffect(async () => {
  const res = await fetch('/api/items/1');
  setText(JSON.stringify(await res.json()));
}, []);
src/Bad.tsx(5,13): error TS2345: Argument of type '() => Promise<void>' is not assignable to parameter of type 'EffectCallback'.
  Type 'Promise<void>' is not assignable to type 'void | Destructor'.

完成コードのように、Effectの中でasync関数を定義して、その場で呼ぶ形にすると、クリーンアップ関数もきちんと返せます。

競合を再現する:遅い応答が後から届いて表示が入れ替わる

ここからが本題です。idを切り替えるたびに取得し直す画面で、先に送ったリクエストの応答が、後から送ったリクエストの応答より遅く届くと、画面には先に送ったほうの結果が残ります。これが競合(レースコンディション)です。

sequenceDiagram
    participant U as 画面
    participant S as API
    U->>S: GET /api/items/1(800msかかる)
    U->>S: GET /api/items/2(100msかかる)
    S-->>U: id=2 の応答 → 表示を更新
    S-->>U: id=1 の応答 → 表示を上書き
    Note over U: 選択中は id=2 なのに id=1 が表示される

再現用のコードです。競合だけに注目するため、ここからの3つの例ではres.okの確認やローディング表示を省いています。

export type Item = { id: number; name: string };
import { useEffect, useState } from 'react';
import type { Item } from './types';

// 対策なし:応答が届いた順に表示を上書きする
export function ItemNaive({ id }: { id: number }) {
  const [item, setItem] = useState<Item | null>(null);

  useEffect(() => {
    console.log(`fetch開始 id=${id}`);
    fetch(`/api/items/${id}`)
      .then((res) => res.json())
      .then((data: Item) => {
        console.log(`応答到着 id=${data.id} → 表示を更新`);
        setItem(data);
      });
  }, [id]);

  return <p id="result">選択中 id={id} / 表示: {item ? `${item.id} ${item.name}` : '…'}</p>;
}

切り替え用の親コンポーネントは共通です。URLの ?v=naive などで、表示するコンポーネントを選べるようにしています。

import { StrictMode, useState, type ComponentType } from 'react';
import { createRoot } from 'react-dom/client';
import { TaskList } from './TaskList';
import { ItemNaive } from './ItemNaive';
import { ItemIgnore } from './ItemIgnore';
import { ItemAbort } from './ItemAbort';
import { ItemHook } from './ItemHook';
import { ItemUse } from './ItemUse';
import { ItemQuery } from './ItemQuery';

const params = new URLSearchParams(location.search);
const view = params.get('v') ?? 'tasks';
const strict = params.get('strict') === '1';

const items: Record<string, ComponentType<{ id: number }>> = {
  naive: ItemNaive, ignore: ItemIgnore, abort: ItemAbort,
  hook: ItemHook, use: ItemUse, query: ItemQuery,
};

function ItemDemo({ View }: { View: ComponentType<{ id: number }> }) {
  const [id, setId] = useState(1);
  return (
    <>
      {[1, 2, 3].map((n) => (
        <button key={n} id={`b${n}`} onClick={() => setId(n)}>id={n}</button>
      ))}
      <View id={id} />
    </>
  );
}

function TaskDemo() {
  const [status, setStatus] = useState('open');
  return (
    <>
      {['open', 'done', 'broken'].map((s) => (
        <button key={s} id={s} onClick={() => setStatus(s)}>{s}</button>
      ))}
      <div id="result"><TaskList status={status} /></div>
    </>
  );
}

const app = view === 'tasks' ? <TaskDemo /> : <ItemDemo View={items[view]} />;
createRoot(document.getElementById('root')!).render(strict ? <StrictMode>{app}</StrictMode> : app);

http://localhost:8861/?v=naive を開き、最初のid=1の取得が終わる前に「id=2」のボタンを押したときのログです。左の数字はページを開き始めてからの経過時間です。

  777ms fetch開始 id=1
  934ms fetch開始 id=2
      -- id=2 をクリック --
 1043ms 応答到着 id=2 → 表示を更新
 1587ms 応答到着 id=1 → 表示を更新
クリック300ms後: 選択中 id=2 / 表示: 2 モニター
最終表示      : 選択中 id=2 / 表示: 1 ノートPC

クリックから300ミリ秒後には正しく「2 モニター」が出ていますが、その後に遅れて届いたid=1の応答が上書きして、選択中はid=2なのに「1 ノートPC」が表示されたままになりました。検索ボックスの入力やタブの切り替えなど、引数が短い間隔で変わる画面で起こりやすい動きです。

直し方1:ignoreフラグで届いた応答を捨てる

Reactの公式ドキュメント(useEffectのリファレンス)で紹介されている形です。クリーンアップ関数でフラグを立て、フラグが立っていたらstateを更新しないようにします。

import { useEffect, useState } from 'react';
import type { Item } from './types';

// ignoreフラグ版:クリーンアップ後に届いた応答は捨てる
export function ItemIgnore({ id }: { id: number }) {
  const [item, setItem] = useState<Item | null>(null);

  useEffect(() => {
    let ignore = false;
    console.log(`fetch開始 id=${id}`);
    fetch(`/api/items/${id}`)
      .then((res) => res.json())
      .then((data: Item) => {
        if (ignore) {
          console.log(`応答到着 id=${data.id} → 無視(ignore=true)`);
          return;
        }
        console.log(`応答到着 id=${data.id} → 表示を更新`);
        setItem(data);
      });
    return () => {
      ignore = true;
    };
  }, [id]);

  return <p id="result">選択中 id={id} / 表示: {item ? `${item.id} ${item.name}` : '…'}</p>;
}

同じ操作をしたときのログです。

  547ms fetch開始 id=1
  751ms fetch開始 id=2
      -- id=2 をクリック --
  867ms 応答到着 id=2 → 表示を更新
 1358ms 応答到着 id=1 → 無視(ignore=true)
クリック300ms後: 選択中 id=2 / 表示: 2 モニター
最終表示      : 選択中 id=2 / 表示: 2 モニター

id=1の応答は届いていますが、捨てられています。なぜこれで直るのかというと、idが変わるとReactは「前のEffectのクリーンアップ → 新しいEffect」の順に実行するからです。ignore はEffectの実行ごとに別の変数なので、id=1のときの ignore だけがtrueになり、id=2のときの ignore はfalseのままです。

API側のログを見ると、リクエストは2つとも最後まで処理されています。

 9771ms 受信 /api/items/1
 9976ms 受信 /api/items/2

直し方2:AbortControllerでリクエストを取り消す

AbortControllerは、リクエストそのものを途中で取り消す仕組みです。controller.signal をfetchに渡し、クリーンアップで controller.abort() を呼びます。MDNのAbortControllerのページには、「abort() が呼び出されると、fetch() のプロミスは AbortError という名前の DOMException で拒否されます」と書かれています。

import { useEffect, useState } from 'react';
import type { Item } from './types';

// AbortController版:クリーンアップでリクエストそのものを取り消す
export function ItemAbort({ id }: { id: number }) {
  const [item, setItem] = useState<Item | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    console.log(`fetch開始 id=${id}`);
    fetch(`/api/items/${id}`, { signal: controller.signal })
      .then((res) => res.json())
      .then((data: Item) => {
        console.log(`応答到着 id=${data.id} → 表示を更新`);
        setItem(data);
      })
      .catch((e: unknown) => {
        if (e instanceof DOMException && e.name === 'AbortError') {
          console.log(`取り消し id=${id}(${e.name}: ${e.message})`);
          return;
        }
        throw e;
      });
    return () => controller.abort();
  }, [id]);

  return <p id="result">選択中 id={id} / 表示: {item ? `${item.id} ${item.name}` : '…'}</p>;
}
  323ms fetch開始 id=1
  500ms fetch開始 id=2
  502ms 取り消し id=1(AbortError: signal is aborted without reason)
      -- id=2 をクリック --
  611ms 応答到着 id=2 → 表示を更新
クリック300ms後: 選択中 id=2 / 表示: 2 モニター
最終表示      : 選択中 id=2 / 表示: 2 モニター

id=2の取得を始めた直後に、id=1のリクエストがAbortErrorで終わっています。API側のログにも違いが出ます。

11668ms 受信 /api/items/1
11845ms 受信 /api/items/2
11849ms 中断 /api/items/1(クライアントが取り消し)

サーバー側でも接続が閉じたことがわかるので、重い処理を途中でやめる作りにしておけば、使われない応答のための処理を減らせます。

AbortErrorはエラー表示にしない

取り消しは、こちらが意図して行った動作です。catchの中で名前を見て、AbortErrorのときは何もせずに抜けるようにします。完成コード(TaskList)から除外の1行を消し、最初の取得が終わる前にdoneへ切り替えると、次のように取り消しがエラーとして表示されたままになりました。

切り替え直後: 取得できませんでした(signal is aborted without reason)
完了後: 取得できませんでした(signal is aborted without reason)

除外の1行を入れた形が次のコードです。

catch (e) {
  if (e instanceof DOMException && e.name === 'AbortError') return;
  setError(e instanceof Error ? e.message : String(e));
  setLoading(false);
}

先に載せた完成コード(TaskList)では、この形をすでに入れてあります。取り消されたときに setLoading(false) を呼ばないのもポイントで、次のリクエストが出しているローディング表示をそのまま保てます。

ignoreフラグとAbortControllerの比較

観点 ignoreフラグ AbortController
表示の入れ替わり 防げる 防げる
通信そのもの 最後まで続く 取り消される
API側のログ 受信のみ 受信のあとに中断が記録された
catchでの追加処理 不要 AbortErrorの除外が必要
fetch以外の非同期処理 そのまま使える signal を受け取れる処理で使える
向いている場面 手早く直したい、signalを渡せない処理 通信量やサーバーの処理も減らしたい

どちらを選んでも表示は正しくなります。fetchを使うならAbortController、signalを渡せないライブラリの関数を呼ぶならignoreフラグ、と覚えておくと選びやすくなります。

StrictModeでfetchが2回呼ばれる理由

開発中に「APIが2回呼ばれている」と気づくことがあります。これは StrictMode の動きで、Reactの公式ドキュメントには「Strict Modeが有効なとき、Reactは最初の本番のセットアップの前に、開発時だけセットアップとクリーンアップを1回余分に実行する」という内容が書かれています。クリーンアップが正しく書けているかを早めに見つけるための仕組みです。

?strict=1&v=abort で、AbortController版をStrictModeの中に入れて開いたときのログです(開発サーバー)。

  220ms fetch開始 id=1
  225ms fetch開始 id=1
  225ms 取り消し id=1(AbortError: signal is aborted without reason)
 1032ms 応答到着 id=1 → 表示を更新
最終表示: 選択中 id=1 / 表示: 1 ノートPC
19188ms 受信 /api/items/1
19189ms 中断 /api/items/1(クライアントが取り消し)
19190ms 受信 /api/items/1

1回目はすぐに取り消され、2回目の応答だけが表示に使われています。ignoreフラグ版では取り消しがないので、2回とも応答が届き、1回目が捨てられます。

  589ms fetch開始 id=1
  590ms fetch開始 id=1
 1418ms 応答到着 id=1 → 無視(ignore=true)
 2252ms 応答到着 id=1 → 表示を更新
最終表示: 選択中 id=1 / 表示: 1 ノートPC

同じStrictModeつきのコードを npx vite build でビルドし、npx vite preview で開いたときは1回だけです。

   94ms fetch開始 id=1
  909ms 応答到着 id=1 → 表示を更新
最終表示: 選択中 id=1 / 表示: 1 ノートPC
条件 fetch開始の回数 表示に使われた応答
開発サーバー・StrictModeあり・AbortController版 2回(1回目は取り消し) 2回目
開発サーバー・StrictModeあり・ignoreフラグ版 2回(1回目は無視) 2回目
開発サーバー・StrictModeなし 1回 1回目
ビルド後・StrictModeあり 1回 1回目

2回呼ばれても表示が正しければ、クリーンアップが正しく書けている証拠です。2回呼ばれるのを止めるためにStrictModeを外したり、フラグで2回目を飛ばしたりするよりも、クリーンアップを書いて「何回呼ばれても同じ結果になる」形にしておくほうが、競合にも強いコードになります。

useFetchにまとめる:カスタムフック化

ここまでの処理(状態の管理・res.okの確認・取り消し・AbortErrorの除外)は、URLが違うだけでどの画面でも同じです。カスタムフックにまとめると、使う側は1行で済みます。

import { useEffect, useState } from 'react';

type State<T> =
  | { status: 'loading'; data: null; error: null }
  | { status: 'success'; data: T; error: null }
  | { status: 'error'; data: null; error: Error };

export function useFetch<T>(url: string): State<T> {
  const [state, setState] = useState<State<T>>({
    status: 'loading', data: null, error: null,
  });

  useEffect(() => {
    const controller = new AbortController();
    setState({ status: 'loading', data: null, error: null });

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
        return res.json() as Promise<T>;
      })
      .then((data) => setState({ status: 'success', data, error: null }))
      .catch((e: unknown) => {
        if (e instanceof DOMException && e.name === 'AbortError') return;
        const error = e instanceof Error ? e : new Error(String(e));
        setState({ status: 'error', data: null, error });
      });

    return () => controller.abort();
  }, [url]);

  return state;
}

状態を status で区別する形(判別可能なユニオン型)にしてあるので、status === 'success' の分岐の中では data がnullでないことをTypeScriptが理解してくれます。

import { useFetch } from './useFetch';
import type { Item } from './types';

export function ItemHook({ id }: { id: number }) {
  const { status, data, error } = useFetch<Item>(`/api/items/${id}`);

  if (status === 'loading') return <p id="result">選択中 id={id} / 読み込み中…</p>;
  if (status === 'error') return <p id="result">選択中 id={id} / エラー: {error.message}</p>;
  return <p id="result">選択中 id={id} / 表示: {data.id} {data.name}</p>;
}

?v=hook で同じ操作をした結果です。表示は正しく、API側には中断が記録されました。

クリック300ms後: 選択中 id=2 / 表示: 2 モニター
最終表示      : 選択中 id=2 / 表示: 2 モニター
[server]
13417ms 受信 /api/items/1
13593ms 受信 /api/items/2
13599ms 中断 /api/items/1(クライアントが取り消し)

useFetchを使うときの注意

注意点 理由 対応
URLは文字列で渡す 依存配列で比較しやすい オブジェクトを渡す設計にするなら中で文字列化する
ヘッダーなどのオプションを引数に足すとき レンダーのたびに新しいオブジェクトになると毎回取得し直す 依存配列に入れる値を文字列や数値に絞る
同じURLを複数のコンポーネントで使う それぞれが別々に取得する 共有したくなったらキャッシュつきのライブラリへ
POSTなどの更新処理 表示と同期する処理ではない ボタンのイベントハンドラーの中でfetchする

React 19のuseとSuspenseで書く

React 19の use は、Promiseを渡すと結果が出るまでコンポーネントを待たせ、近くの Suspense にローディング表示を任せられるAPIです。ローディングとエラーの分岐がコンポーネントの外へ出るので、データを表示する部分は「データがある前提」で書けます。

import { Component, Suspense, use, type ReactNode } from 'react';
import type { Item } from './types';

// 同じURLには同じPromiseを返す(レンダーのたびに作り直さない)
const cache = new Map<string, Promise<unknown>>();

function fetchJson<T>(url: string): Promise<T> {
  let promise = cache.get(url);
  if (!promise) {
    promise = fetch(url).then((res) => {
      if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
      return res.json();
    });
    cache.set(url, promise);
  }
  return promise as Promise<T>;
}

function ItemView({ id }: { id: number }) {
  const item = use(fetchJson<Item>(`/api/items/${id}`));
  return <p id="result">選択中 id={id} / 表示: {item.id} {item.name}</p>;
}

class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
  state = { error: null as Error | null };
  static getDerivedStateFromError(error: Error) {
    return { error };
  }
  render() {
    if (this.state.error) return <p id="result">エラー: {this.state.error.message}</p>;
    return this.props.children;
  }
}

export function ItemUse({ id }: { id: number }) {
  return (
    <ErrorBoundary key={id}>
      <Suspense fallback={<p id="result">選択中 id={id} / 読み込み中…</p>}>
        <ItemView id={id} />
      </Suspense>
    </ErrorBoundary>
  );
}

?v=use で同じ操作をした結果です。

クリック300ms後: 選択中 id=2 / 表示: 2 モニター
最終表示      : 選択中 id=2 / 表示: 2 モニター
[server]
15208ms 受信 /api/items/1
15397ms 受信 /api/items/2

表示は正しく切り替わりました。use は「今のidに対応するPromise」の結果だけを読むので、id=1の応答が後から届いても表示には影響しません。一方、このコードではリクエストを取り消していないため、API側に中断は記録されていません。

公式ドキュメントのuseのページで確認できた注意点は次の3つです。

注意点 内容
Promiseは使い回す レンダーのたびに新しいPromiseを作ると、そのたびにローディング表示へ戻る。上のコードではMapに入れて同じURLには同じPromiseを返している
try-catchで囲まない エラーはError Boundaryで受ける。上のコードではクラスコンポーネントで用意した
条件分岐の中でも呼べる use はほかのフックと違い、ifやループの中で呼べる

Mapに入れたPromiseは、消さない限り残り続けます。再取得や有効期限の管理まで必要になったら、次のTanStack Queryのようなライブラリに任せるのが近道です。

TanStack Queryで書く

TanStack Queryは、取得したデータのキャッシュ、ローディングとエラーの状態、取り消しまでをまとめて面倒を見てくれるライブラリです。Reactの公式ドキュメントでも、Effectで直接取得する代わりの選択肢として、フレームワーク組み込みの仕組みと並んで名前が挙がっています。

import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import type { Item } from './types';

const queryClient = new QueryClient();

function ItemView({ id }: { id: number }) {
  const { data, error, isPending } = useQuery({
    queryKey: ['item', id],
    queryFn: async ({ signal }): Promise<Item> => {
      const res = await fetch(`/api/items/${id}`, { signal });
      if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
      return res.json();
    },
    retry: false,
  });

  if (isPending) return <p id="result">選択中 id={id} / 読み込み中…</p>;
  if (error) return <p id="result">選択中 id={id} / エラー: {error.message}</p>;
  return <p id="result">選択中 id={id} / 表示: {data.id} {data.name}</p>;
}

export function ItemQuery({ id }: { id: number }) {
  return (
    <QueryClientProvider client={queryClient}>
      <ItemView id={id} />
    </QueryClientProvider>
  );
}

queryFn の引数で受け取った signal をfetchへ渡しているのがポイントです。?v=query で同じ操作をした結果、表示は正しく、API側にも中断が記録されました。

クリック300ms後: 選択中 id=2 / 表示: 2 モニター
最終表示      : 選択中 id=2 / 表示: 2 モニター
[server]
17384ms 受信 /api/items/1
17581ms 受信 /api/items/2
17582ms 中断 /api/items/1(クライアントが取り消し)

res.okの確認は、ここでも自分で書きます。queryFn が例外を投げたときに error へ入る作りなので、HTTPエラーを例外に変える1行は同じように必要です。

4つの書き方の比較表

同じAPI・同じ操作で動かした結果をまとめます。行数は、この記事に載せたファイルの行数です。

観点 useEffect+fetch useFetch(自作フック) use+Suspense TanStack Query
追加のライブラリ なし なし なし(React 19) @tanstack/react-query
競合の対策 自分で書く フックの中に1回書く 今のPromiseだけを読む ライブラリが行う
今回の操作での最終表示 対策なしは入れ替わり、対策ありは正しい 正しい 正しい 正しい
API側の中断の記録 AbortController版であり あり なし(取り消しを書いていない) あり(signal を渡した)
ローディングの書き方 stateで分岐 status で分岐 Suspense の fallback isPending で分岐
エラーの書き方 stateで分岐 status で分岐 Error Boundary error で分岐
キャッシュ なし なし 自分で用意(Map) あり
今回のコードの行数 28行(AbortController版) 33行+使う側10行 43行 28行

どれを選ぶかの判断表

状況 選び方
仕組みを理解したい、取得は1〜2か所だけ useEffect+fetch(AbortController版)
取得する画面が増えてきた、ライブラリは足したくない useFetchにまとめる
ローディング表示を親でまとめたい、React 19を使っている use+Suspense
同じデータを複数の画面で使う、再取得やキャッシュが欲しい TanStack Query
Next.jsなどのフレームワークを使っている フレームワークが用意しているデータ取得の仕組み

Reactの公式ドキュメントは、Effectで直接取得する形の弱みとして、サーバーレンダリングで初期HTMLにデータが入らないこと、親から子へ順番に取得が連なりやすいこと、キャッシュがないこと、競合を避けるための定型コードが多いことを挙げています。小さく始めるならuseEffect、規模が大きくなったらライブラリやフレームワークへという流れで考えると、無理なく移れます。

つまずきやすい点と直し方

症状 原因 直し方
500なのにエラー表示にならない res.okを確認していない if (!res.ok) throw new Error(...) を入れる
切り替えるとエラー表示が出る AbortErrorをエラーとして扱っている catchで名前を見て除外する
選択と表示が食い違う 競合 クリーンアップでignoreフラグかabort
開発中だけAPIが2回呼ばれる StrictModeの確認用の再実行 クリーンアップを書く(ビルド後は1回)
取得が止まらず何度も走る 依存配列がない、または毎回新しいオブジェクトを入れている 依存配列を書き、文字列や数値を入れる
idを変えても取得し直さない 依存配列に入れ忘れ Effectの中で使った値を依存配列に入れる
TS2345でコンパイルが止まる useEffectにasync関数を直接渡している 中でasync関数を定義して呼ぶ
切り替えの間、前のデータが出たままになる 取得開始時に状態を戻していない 取得開始で loading をtrueにする

取得前の確認チェックリスト

  • res.okを確認している(HTTPエラーを例外に変えている)
  • クリーンアップ関数を返している(ignoreフラグかabort)
  • AbortErrorをエラー表示から除外している
  • 依存配列に、Effectの中で使った値を入れている
  • ローディング・エラー・空・データの4つを出し分けている
  • StrictModeで開いても表示が正しい

よくある質問

Q. fetchの代わりにPromiseチェーン(then)とasync/awaitのどちらで書くとよいですか? A. どちらでも同じ動きです。この記事では、完成コード(TaskList)をasync/await、競合の例とuseFetchをthenで書きました。async/awaitで書くときは、Effectの中で関数を定義して呼ぶ形にします。

Q. ignoreフラグとAbortControllerは両方書くべきですか? A. fetchを使うならAbortControllerだけで表示の入れ替わりを防げます。今回のログでも、取り消されたリクエストはAbortErrorで終わり、stateの更新まで進みませんでした。signalを渡せない処理が混ざるときに、ignoreフラグを併用します。

Q. StrictModeで2回呼ばれるのを止めたほうがよいですか? A. そのままで大丈夫です。ビルド後は1回になることを確かめました。開発中に2回呼ばれても表示が正しければ、クリーンアップが正しく書けています。

Q. ボタンを押したときのPOSTもuseEffectで書きますか? A. 送信や更新は、ボタンのイベントハンドラーの中で直接fetchします。useEffectは「画面に表示している内容を外部のデータと合わせる」ための場所なので、表示用の取得に使います。

まとめ

  • 基本形は「useEffectの中でfetch、結果をuseStateへ、クリーンアップで取り消し」
  • fetchは500でも例外を投げないので、res.ok を確認して自分で例外にする
  • ローディング・エラー・空・データの4状態は、上から順に早期リターンで出し分ける
  • 競合は、遅い応答が後から届いて表示を上書きする現象。ignoreフラグかAbortControllerで防げる
  • AbortControllerはサーバー側にも中断が伝わる。AbortErrorはエラー表示から除外する
  • StrictModeの2回実行は開発時だけ。クリーンアップが書けていれば表示は正しいまま
  • 取得する画面が増えたら useFetch、キャッシュまで欲しくなったら TanStack Query やフレームワークの仕組みへ

参考・出典

  • React公式ドキュメント「useEffect」(Fetching data with Effects、Strict Modeでの再実行):https://react.dev/reference/react/useEffect
  • React公式ドキュメント「use」:https://react.dev/reference/react/use
  • MDN「AbortController」:https://developer.mozilla.org/ja/docs/Web/API/AbortController

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「ReactでAPIデータ取得 fetchとuseEffectの書き方と競合対策」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?