ReactのuseEffectでのfetchの競合をAbortControllerで防ぐ方法

​​React​AbortController,fetch,StrictMode,TypeScript,useEffect

useEffect の中で fetch すると、検索語を速く打ち替えたときに、古い検索の応答が後から届いて表示を上書きすることがあります。エフェクトの片付け関数で AbortController の abort() を呼ぶと、古いリクエストそのものが中止され、入力どおりの結果だけが残りました。中止したときに出る AbortError の扱いと、abort() だけでは防げない場合も、Chromium で動かして確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 141 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.56.1 です。.tsx を tsc で型チェックしてから esbuild で変換し(開発用のビルド)、Playwright で操作した Chromium で動かしました。API は、検証用に Node.js で作ったサーバーです。

古い検索の応答が、後から表示を上書きする

検証用に、猫の名前を前方一致で探す API(/api/search?q=…)を作りました。名前は「たま」「たまこ」「たろう」「みけ」「くろ」の5つです。1文字の検索は600ミリ秒、2文字以上の検索は100ミリ秒待ってから応答します。短い検索語ほど結果が多く遅い、という状況をわざと作っています。

まず、片付けを書かずに fetch するコンポーネントです。公式ドキュメント「You Might Not Need an Effect」のデータ取得の節で、避けるべき例として挙がっている書き方と同じ形です。

tsx
// 1. 片付けなし
export function SearchNoCleanup({ query }: { query: string }) {
  const [result, setResult] = useState<Result | null>(null);
  useEffect(() => {
    if (!query) return;
    fetch(`/api/search?q=${encodeURIComponent(query)}`)
      .then((res) => res.json())
      .then((names: string[]) => setResult({ query, names }));
  }, [query]);
  return <Results result={result} />;
}

Result は検索語と名前の配列の組で、Results は『「た」の結果: …』のように表示し、error を渡すと「エラー: …」も出すだけのコンポーネントです。コードの先頭のコメントの番号は、この記事で紹介する順に付けています。

このコンポーネントの query には、入力欄の値を渡しています。下の Search には、URL のパラメーターで選んだコンポーネント(SearchNoCleanup など)が入ります。

tsx
function App() {
  const [query, setQuery] = useState(params.get('q') ?? '');
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="猫の名前" />
      <Search query={query} />
    </>
  );
}

Playwright で入力欄の値を「た」にし、50ミリ秒後に「たま」に書き換えました(日本語入力の変換は経ていません。以下、「打つ」と書きます)。表示の移り変わり(それぞれの時点の入力欄の値つき)と、サーバーとブラウザの記録は次のとおりです。

text
--- SearchNoCleanup: 「た」→ 50ミリ秒後に「たま」 ---
  最後の入力欄: たま
  表示: (結果なし)(入力欄: )
  表示: 「たま」の結果: たま / たまこ(入力欄: たま)
  表示: 「た」の結果: たま / たまこ / たろう(入力欄: たま)
  サーバー: 受信 q=た(600ミリ秒後に応答)
  サーバー: 受信 q=たま(100ミリ秒後に応答)
  サーバー: 応答を送信 q=たま
  サーバー: 応答を送信 q=た
  リクエスト完了 q=たま
  リクエスト完了 q=た

「たま」の結果がいったん表示された後、遅れて届いた「た」の応答で上書きされました。入力欄は「たま」なのに、「たろう」が混ざった結果が最後まで残っています。公式ドキュメントでは、このように、リクエストを送った順とは違う順で応答が返ってくることを「競合状態(race condition)」と呼んでいます。

片付け関数で abort() を呼ぶ

fetch の第2引数に AbortController の signal を渡し、エフェクトの片付け関数で abort() を呼びます。useEffect のリファレンスによると、依存配列の値が変わると、React は前の値で片付け関数を呼んでから、新しい値でエフェクトを動かします。そのため、前の検索語のリクエストが中止されます。

tsx
// 2. AbortController で、古いリクエストを中止する
export function SearchAbort({ query }: { query: string }) {
  const [result, setResult] = useState<Result | null>(null);
  const [error, setError] = useState('');
  useEffect(() => {
    if (!query) return;
    const controller = new AbortController();
    fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
      .then((res) => res.json())
      .then((names: string[]) => {
        setResult({ query, names });
        setError('');
      })
      .catch((e: unknown) => {
        if (controller.signal.aborted) {
          console.log(`中止された q=${query}: ${(e as Error).name}`);
          return;
        }
        setError(String(e));
      });
    return () => controller.abort();
  }, [query]);
  return <Results result={result} error={error} />;
}

同じように「た」→「たま」と打ちました。

text
--- SearchAbort: 「た」→ 50ミリ秒後に「たま」 ---
  最後の入力欄: たま
  表示: (結果なし)(入力欄: )
  表示: 「たま」の結果: たま / たまこ(入力欄: たま)
  サーバー: 受信 q=た(600ミリ秒後に応答)
  サーバー: 受信 q=たま(100ミリ秒後に応答)
  サーバー: 応答の前に接続が切られた q=た
  サーバー: 応答を送信 q=たま
  console: 中止された q=た: AbortError
  リクエスト失敗 q=た: net::ERR_ABORTED
  リクエスト完了 q=たま

表示は「たま」の結果のまま変わりませんでした。サーバーの側では、「た」の応答を返す前に接続が切られています。Chromium は「た」のリクエストを net::ERR_ABORTED で失敗として扱い、fetch の Promise は AbortError で失敗しました。

次の GIF は、2つの書き方を上下に並べ、両方の入力欄に「た」→「たま」と打った様子です。動きが見えるように、サーバーの待ち時間を1文字の検索は1,500ミリ秒、2文字以上は300ミリ秒に延ばし、打ち直すまでの間も600ミリ秒にしています。

上の「片付けなし」では、「たま」の結果が出た後に「た」の結果に置き換わる。下の「AbortController で中止」では、「た」のリクエストが中止され、「たま」の結果のまま残る
「た」→「たま」と打ったときの、片付けなし(上)と AbortController あり(下)の動き。入力やリクエスト、表示が変わった瞬間をピンクで示す

中止で起きた AbortError は、エラーとして表示しない

MDN の AbortSignal の説明にあるとおり、abort() を呼ぶと、fetch() の Promise は AbortError という名前の DOMException で失敗します。catch でこれを区別しないと、自分で中止したものまでエラーとして扱ってしまいます。中止を区別せず、catch で受けたものをすべて表示するコンポーネントで確かめました。

tsx
// 3. 中止で起きたエラーも、ふつうのエラーとして表示してしまう
export function SearchAbortNoFilter({ query }: { query: string }) {
  const [result, setResult] = useState<Result | null>(null);
  const [error, setError] = useState('');
  useEffect(() => {
    if (!query) return;
    const controller = new AbortController();
    fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
      .then((res) => res.json())
      .then((names: string[]) => setResult({ query, names }))
      .catch((e: unknown) => setError(String(e)));
    return () => controller.abort();
  }, [query]);
  return <Results result={result} error={error} />;
}
text
--- SearchAbortNoFilter: 「た」→ 50ミリ秒後に「たま」 ---
  最後の入力欄: たま
  表示: (結果なし)(入力欄: )
  表示: エラー: AbortError: signal is aborted without reason | (結果なし)(入力欄: たま)
  表示: エラー: AbortError: signal is aborted without reason | 「たま」の結果: たま / たまこ(入力欄: たま)
  サーバー: 受信 q=た(600ミリ秒後に応答)
  サーバー: 受信 q=たま(100ミリ秒後に応答)
  サーバー: 応答の前に接続が切られた q=た
  サーバー: 応答を送信 q=たま
  リクエスト失敗 q=た: net::ERR_ABORTED
  リクエスト完了 q=たま

「たま」の結果は正しく出ましたが、「AbortError: signal is aborted without reason」というエラーの表示が残りました。上の SearchAbort では、catch の中で controller.signal.aborted を確かめ、自分で中止したときは、エラーとして扱わずに処理を終えています。MDN にある、エラーの名前で中止とタイムアウトを見分ける例のように、エラーの名前を見て AbortError かどうかを調べる方法もありますが、signal.aborted を見れば、そのリクエストを中止したかどうかを直接確かめられます。

ignore フラグとの違い

React の公式ドキュメントでは、競合を防ぐ方法として、ignore というフラグを使う書き方が紹介されています(useEffect のリファレンスにある、エフェクトでデータを取る例)。片付け関数でフラグを true にして、古い応答を捨てる方法です。

tsx
// 4. ignore フラグで、古い応答を捨てる
export function SearchIgnore({ query }: { query: string }) {
  const [result, setResult] = useState<Result | null>(null);
  useEffect(() => {
    if (!query) return;
    let ignore = false;
    fetch(`/api/search?q=${encodeURIComponent(query)}`)
      .then((res) => res.json())
      .then((names: string[]) => {
        if (!ignore) setResult({ query, names });
      });
    return () => {
      ignore = true;
    };
  }, [query]);
  return <Results result={result} />;
}
text
--- SearchIgnore: 「た」→ 50ミリ秒後に「たま」 ---
  最後の入力欄: たま
  表示: (結果なし)(入力欄: )
  表示: 「たま」の結果: たま / たまこ(入力欄: たま)
  サーバー: 受信 q=た(600ミリ秒後に応答)
  サーバー: 受信 q=たま(100ミリ秒後に応答)
  サーバー: 応答を送信 q=たま
  サーバー: 応答を送信 q=た
  リクエスト完了 q=たま
  リクエスト完了 q=た

表示は、AbortController を使ったときと同じく「たま」の結果のままでした。違うのはサーバーとブラウザの記録です。ignore フラグでは、「た」の応答も最後まで送られ、ブラウザでもリクエストが完了しています。その結果を、コードの側で捨てています。AbortController では、応答の前に接続が切られました。

書き方 最後の表示 古い「た」のリクエスト AbortError
片付けなし 「た」の結果(誤り) 応答を受け取り、表示に使う 起きない
ignore フラグ 「たま」の結果 応答を受け取り、捨てる 起きない
AbortController 「たま」の結果 応答の前に中止する 起きる

エフェクトの同期の解説にある、データ取得の節でも、データを取るエフェクトの片付け関数では、fetch を中止するか、結果を無視するかのどちらかをするように、と書かれています。この検証では、どちらでも表示は正しくなりましたが、中止した方は、古いリクエストの応答を受け取らずに済みました。

fetch の後に続く処理は、abort() では止まらない

MDN の AbortController の説明によると、abort() で止められるのは、signal を渡した fetch と、その応答の本文の読み込みです。fetch の後に signal を受け取らない非同期の処理が続くと、中止した後もその処理は進み、最後に state を更新してしまいます。

確かめるため、写真を読み込む処理の代わりに、1匹ごとに150ミリ秒かかる addPhotos を用意しました。名前の後ろに「📷」を付けるだけの関数で、signal は受け取りません。

tsx
// 写真を読み込む処理の代わり。1匹ごとに150ミリ秒かかり、signal は受け取らない
function addPhotos(names: string[]): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => resolve(names.map((n) => `${n}📷`)), names.length * 150),
  );
}

// 5. fetch の後に、signal を受け取らない非同期の処理が続く
export function SearchAbortThenStep({ query }: { query: string }) {
  const [result, setResult] = useState<Result | null>(null);
  useEffect(() => {
    if (!query) return;
    const controller = new AbortController();
    async function load() {
      try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal });
        const names: string[] = await res.json();
        const withPhotos = await addPhotos(names);
        setResult({ query, names: withPhotos });
      } catch (e) {
        if (!controller.signal.aborted) throw e;
      }
    }
    load();
    return () => controller.abort();
  }, [query]);
  return <Results result={result} />;
}

今度は、「た」の応答が届いてから50ミリ秒後に「たま」と打ちました。「た」の addPhotos が終わる前に、片付け関数の abort() が呼ばれることになります。

text
--- SearchAbortThenStep: 「た」の応答が届いた50ミリ秒後に「たま」 ---
  最後の入力欄: たま
  表示: (結果なし)(入力欄: )
  表示: 「た」の結果: たま📷 / たまこ📷 / たろう📷(入力欄: たま)
  表示: 「たま」の結果: たま📷 / たまこ📷(入力欄: たま)
  サーバー: 受信 q=た(600ミリ秒後に応答)
  サーバー: 応答を送信 q=た
  サーバー: 受信 q=たま(100ミリ秒後に応答)
  サーバー: 応答を送信 q=たま
  リクエスト完了 q=た
  リクエスト完了 q=たま

abort() を呼んだときには「た」の fetch も本文の読み込みも終わっていたので、何も失敗しませんでした。入力欄が「たま」になっているのに、「た」の結果がいったん表示されています。この検証では、すぐ後に「たま」の結果が届いて、最後は正しい表示になりました。「た」の addPhotos が「たま」の処理より遅く終われば、「た」の結果が最後に残るはずです(その組み合わせは試していません)。

setResult の直前に signal.aborted を確かめると、この表示は出なくなりました。

tsx
// 6. 5 の setResult の直前で、signal.aborted を確かめる
export function SearchAbortChecked({ query }: { query: string }) {
  const [result, setResult] = useState<Result | null>(null);
  useEffect(() => {
    if (!query) return;
    const controller = new AbortController();
    const { signal } = controller;
    async function load() {
      try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal });
        const names: string[] = await res.json();
        const withPhotos = await addPhotos(names);
        if (signal.aborted) return;
        setResult({ query, names: withPhotos });
      } catch (e) {
        if (!signal.aborted) throw e;
      }
    }
    load();
    return () => controller.abort();
  }, [query]);
  return <Results result={result} />;
}
text
--- SearchAbortChecked: 「た」の応答が届いた50ミリ秒後に「たま」 ---
  最後の入力欄: たま
  表示: (結果なし)(入力欄: )
  表示: 「たま」の結果: たま📷 / たまこ📷(入力欄: たま)
  サーバー: 受信 q=た(600ミリ秒後に応答)
  サーバー: 応答を送信 q=た
  サーバー: 受信 q=たま(100ミリ秒後に応答)
  サーバー: 応答を送信 q=たま
  リクエスト完了 q=た
  リクエスト完了 q=たま
ポイント

abort() は、まだ終わっていないリクエストを止めるものです。古い結果で state を更新しないことまでは保証しません。state を更新する直前に signal.aborted を確かめると、ignore フラグと同じ働きも兼ねられます。

エフェクトの同期の解説にある練習問題の「Fix fetching inside an Effect」の解答でも、AbortController だけでは競合を防ぐには足りないと書かれています。fetch の後に別の非同期の処理がつながることがあるので、ignore のような明示的なフラグを使うのが最も確実だ、という説明です。

StrictMode では、最初のリクエストが中止される

開発中に <StrictMode> で包むと、React は本来の準備の前に、エフェクトの準備と片付けを1回余分に行います(useEffect のリファレンスの注意事項)。最初から「たま」を検索した状態で開き、サーバーの記録を見ました。

text
--- SearchIgnore: StrictMode で開く(最初の検索語は「たま」) ---
  最後の入力欄: たま
  表示: (結果なし)(入力欄: たま)
  表示: 「たま」の結果: たま / たまこ(入力欄: たま)
  サーバー: 受信 q=たま(100ミリ秒後に応答)
  サーバー: 応答を送信 q=たま
  サーバー: 受信 q=たま(100ミリ秒後に応答)
  サーバー: 応答を送信 q=たま
  リクエスト完了 q=たま
  リクエスト完了 q=たま
--- SearchAbort: StrictMode で開く(最初の検索語は「たま」) ---
  最後の入力欄: たま
  表示: (結果なし)(入力欄: たま)
  表示: 「たま」の結果: たま / たまこ(入力欄: たま)
  サーバー: 受信 q=たま(100ミリ秒後に応答)
  サーバー: 応答の前に接続が切られた q=たま
  サーバー: 受信 q=たま(100ミリ秒後に応答)
  サーバー: 応答を送信 q=たま
  console: 中止された q=たま: AbortError
  リクエスト失敗 q=たま: net::ERR_ABORTED
  リクエスト完了 q=たま

どちらも、サーバーには「たま」のリクエストが2回届きました。ignore フラグでは2回とも応答が送られ、AbortController では1回目が応答の前に切られています。この検証では、中止しても、1回目のリクエストはサーバーまで届いていました。

エフェクトの同期の解説にある、データ取得の節には、開発中は Network タブにリクエストが2回出るが、問題はないと書かれています。この検証では、AbortController を使った書き方の1回目のリクエストは、net::ERR_ABORTED で失敗したと記録されました(DevTools の Network タブでの見え方は確かめていません)。中止されたリクエストは catch に AbortError として届くので、ここでも signal.aborted を確かめておくと、エラーの表示が出ません。

エフェクトでのデータ取得を書くときの形

useEffect で fetch するときは、次の3つをそろえます。

  1. エフェクトの中で AbortController を作り、fetch に signal を渡す
  2. 片付け関数で abort() を呼ぶ
  3. state を更新する前と catch の中で、signal.aborted を確かめる

上の SearchAbortChecked が、この形です。SearchAbortChecked は、この記事の3つの場面(「た」→50ミリ秒後に「たま」、応答が届いてから「たま」、StrictMode で開く)のどれでも、「たま」の結果だけを表示しました。fetch の後に処理が続かない場合でも、3を入れておけば、後から fetch と state の更新の間に処理を足しても、古い結果で state を更新せずに済みます。

なお、useEffect のリファレンスでは、エフェクトでのデータ取得には、競合のほかにも、サーバーでは動かない、ネットワークのウォーターフォールが起きやすい、キャッシュがない、といった難しさがあると書かれています。そのため、フレームワークに組み込まれたデータ取得の仕組みや、TanStack Query などのクライアント側のキャッシュを使う方法が勧められています。この記事では、それらは試していません。

​Posted by 86