Reactで、fetchに時間切れと中止ボタンを両方付ける方法

​​React​AbortController,AbortSignal,fetch,Playwright,TypeScript

fetch に「3秒で時間切れ」と「中止ボタン」の両方を付けるには、中止ボタン用の AbortController のシグナルと AbortSignal.timeout(3000) を、AbortSignal.any() で1つにまとめて渡します。どちらか早いほうでリクエストが止まります。React では、この処理をカスタムフックにまとめ、AbortController を useRef に持たせて中止ボタンから abort() を呼べるようにしました。応答が遅い API を一定の時間で打ち切るタイムアウトと、利用者が中止ボタンで止めるキャンセルを、1つのリクエストに両方付けたいときに使えます。

止まった理由(時間切れか、中止ボタンか)は、catch で受け取ったエラーではなく、まとめたシグナルの reason から読みます。検証環境の Chromium では、応答の本文を読んでいる途中で時間切れになったとき、エラーの名前が TimeoutError ではなく AbortError になり、中止ボタンを押したときと区別できなかったためです。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153(ヘッドレス)です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用ビルドを StrictMode の中で動かしました。API は、検証用に Node.js で作ったサーバーです。Firefox と Safari、Node.js の fetch、ページが戻る・進むのキャッシュ(bfcache)に入ったときの時間切れの動きは試していません。

2つのシグナルを AbortSignal.any() でまとめる

AbortSignal.timeout() は、指定したミリ秒がたつと自動で中止されるシグナルを返します。中止の理由(reason)は、名前が TimeoutError の DOMException です。

AbortSignal.any() は、渡したシグナルのどれか1つが中止された時点で、自分も中止されるシグナルを返します。理由には、最初に中止されたシグナルの理由がそのまま入ります。中止ボタンで abort() を引数なしで呼んだときの理由は、名前が AbortError の DOMException です。

これを使って、取得の状態を返すカスタムフックを書きました。

ts
import { useEffect, useRef, useState } from 'react';

export type FetchState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'done'; text: string }
  | { status: 'timeout' }
  | { status: 'canceled' }
  | { status: 'error'; message: string };

export function useFetchWithCancel(timeoutMs: number) {
  const [state, setState] = useState<FetchState>({ status: 'idle' });
  const controllerRef = useRef<AbortController | null>(null);

  // コンポーネントが消えるときは、取得中のリクエストを中止する
  useEffect(() => () => controllerRef.current?.abort(), []);

  async function start(url: string) {
    controllerRef.current?.abort(); // 前のリクエストが残っていれば中止する
    const controller = new AbortController();
    controllerRef.current = controller;
    // 中止ボタン用のシグナルと、時間切れのシグナルを1つにまとめる
    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
    setState({ status: 'loading' });
    try {
      const res = await fetch(url, { signal });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const text = await res.text();
      setState({ status: 'done', text });
    } catch (err) {
      if (controllerRef.current !== controller) return; // 新しいリクエストに切り替わった
      // 止めた理由は、err ではなく signal.reason から読む
      const reason = signal.aborted ? signal.reason : err;
      if (reason instanceof DOMException && reason.name === 'TimeoutError') {
        setState({ status: 'timeout' });
      } else if (reason instanceof DOMException && reason.name === 'AbortError') {
        setState({ status: 'canceled' });
      } else {
        setState({ status: 'error', message: String(err) });
      }
    } finally {
      if (controllerRef.current === controller) controllerRef.current = null;
    }
  }

  function cancel() {
    controllerRef.current?.abort();
  }

  return { state, start, cancel };
}

AbortController は取得のたびに新しく作り、useRef に入れておきます。cancel() は、その abort() を呼ぶだけです。AbortController は画面の表示に使う値ではないので、state ではなく、値を変えても再レンダーが起きない useRef に入れています。

時間切れのシグナルも、start() の中で取得のたびに作ります。検証環境で、すでに中止されたシグナル(AbortSignal.abort())を AbortSignal.any() に渡したところ、まとめたシグナルも作った時点で中止済みになっていました。つまり、時間切れのシグナルを使い回すと、それが一度中止されたあとは、次の取得でまとめたシグナルも最初から中止済みになります。

型については、TypeScript に入っている DOM の型定義(typescript 7.0.2 の lib.dom.d.ts)に any(signals: AbortSignal[]) と timeout(milliseconds: number) が含まれています。tsconfig の lib に DOM を入れたふつうの設定のまま、型チェックが通りました。

ボタンに組み込む

フックを使うコンポーネントです。中止ボタンは、取得中だけ押せるようにしています。

tsx
function CatFetcher({ url }: { url: string }) {
  const { state, start, cancel } = useFetchWithCancel(3000);
  const loading = state.status === 'loading';
  const elapsed = useElapsed(loading);
  return (
    <section className="fetcher">
      <div className="buttons">
        <button onClick={() => start(url)}>取得する</button>
        <button onClick={cancel} disabled={!loading}>中止</button>
      </div>
      <p className="status" data-status={state.status}>{message(state)}</p>
      <p className="elapsed">経過 {(elapsed / 1000).toFixed(1)} 秒</p>
    </section>
  );
}

message() は state に合わせて「取得を中止しました」「3秒たっても終わらないため、取得をやめました」などの文を返す関数で、useElapsed() は取得中の経過時間を0.1秒ごとに数えるフックです。検証用のコードでは、後で比べるために URL のパラメーターで2つのフックを切り替えていますが、ここでは省いています。

応答に5秒かかる API を相手に、「取得する」「中止」「取得する」の順に押した様子です。1回目は中止ボタンで止まり、2回目は3秒の時間切れで止まりました。

「取得する」を押して1.6秒ほどで「中止」を押すと「取得を中止しました」と出て、もう一度「取得する」を押すと、経過3.0秒で「3秒たっても終わらないため、取得をやめました」と出る画面
中止ボタンで止めたときと、3秒の時間切れで止まったときの表示(応答に5秒かかる API)

時間切れと中止ボタンで、サーバーとの接続まで切れるか

Playwright で Chromium を操作し、画面の表示と、サーバーが記録した時刻(リクエストを受けてからのミリ秒)を並べました。まず、1秒で返る API です。時間切れの3秒より早いので、そのまま取得できました。

text
--- 1. 1秒で返る API(時間切れは3秒)
  押す前: まだ取得していません /中止ボタン disabled: true
  0.3秒後: 取得しています… /中止ボタン disabled: false
  1032ms 後: 取得しました:たま(3歳)
  サーバー:     0ms /api/cat?delay=1000 受信
  サーバー:  1002ms /api/cat?delay=1000 応答を送り終えた

5秒かかる API では、3秒で時間切れになりました。中止ボタンを1秒後に押したときは、その時点で止まりました。どちらも、サーバー側で「送り終える前に接続が切れた」と記録されており、ブラウザの中で結果を捨てただけではなく、通信そのものが切れています。

text
--- 2. 5秒かかる API(時間切れは3秒)
  3038ms 後: 3秒たっても終わらないため、取得をやめました
  サーバー:     0ms /api/cat?delay=5000 受信
  サーバー:  3000ms /api/cat?delay=5000 送り終える前に接続が切れた
--- 3. 5秒かかる API で、1秒後に中止ボタンを押す
  1088ms 後: 取得を中止しました
  サーバー:     0ms /api/cat?delay=5000 受信
  サーバー:  1051ms /api/cat?delay=5000 送り終える前に接続が切れた
  さらに2.5秒後(時間切れの3秒を過ぎた): 取得を中止しました

中止ボタンで止めたあとも、時間切れのシグナルのタイマーは動き続けます。MDN(英語版)の AbortSignal.timeout() の説明にも、AbortSignal.any() でまとめた別のシグナルが中止されても時間切れのタイマーは取り消されない、と書かれています。検証環境で、0.5秒の時間切れのシグナルと AbortController のシグナルをまとめ、0.1秒後に abort() を呼んで試しました。時間切れのシグナルは、abort() の直後はまだ中止されておらず、その0.6秒後には中止済みになっていました。それでも、まとめたシグナルの理由は最初の AbortError のままでした。上のコンポーネントでも、時間切れの3秒を過ぎたあとの表示は「取得を中止しました」のままです。

本文を読んでいる途中の時間切れは、err.name では見分けられなかった

MDN の AbortSignal.any() の例では、catch で受け取ったエラーの name が AbortError か TimeoutError かで、中止と時間切れを分けています。比べるために、catch の中だけをこの書き方にしたフックも用意しました。

ts

if (err instanceof DOMException && err.name === 'TimeoutError') {
        setState({ status: 'timeout' });
      } else if (err instanceof DOMException && err.name === 'AbortError') {
        setState({ status: 'canceled' });

応答のヘッダーと本文の最初はすぐに返し、本文の残りを5秒後に返す API を用意して、この書き方で試しました。3秒で止まったのに、表示は「取得を中止しました」でした。

text
--- 0b. ヘッダーはすぐ返り、本文の残りに5秒かかる API
  3047ms 後: 取得を中止しました
  サーバー:     0ms /api/slow-body 受信
  サーバー:     0ms /api/slow-body ヘッダーと本文の最初を送った
  サーバー:  3000ms /api/slow-body 送り終える前に接続が切れた

fetch() そのものは、ヘッダーが届いた時点で解決しています。止まったのは、その後の res.text() です。時間切れを0.5秒にして、ブラウザの中で同じ状況を作り、res.text() が失敗したときのエラーを調べました。

text
--- 8. 本文を読んでいる途中で止まったときの、res.text() のエラー
{
  "any() でまとめたシグナル": {
    "rejectedAt": "res.text()",
    "name": "AbortError",
    "message": "The user aborted a request.",
    "sameAsSignalReason": false,
    "signalReasonName": "TimeoutError"
  },
  "AbortSignal.timeout() だけ": {
    "rejectedAt": "res.text()",
    "name": "AbortError",
    "message": "The user aborted a request.",
    "sameAsSignalReason": false,
    "signalReasonName": "TimeoutError"
  }
}

シグナルの理由は TimeoutError なのに、res.text() のエラーは別の AbortError でした。AbortSignal.any() を使わず AbortSignal.timeout() だけを渡しても同じです。一方、ヘッダーが届く前に止まったときは、fetch() のエラーがシグナルの理由と同じオブジェクト(TimeoutError)でした。

Fetch の仕様の fetch() の呼び出しを中止する手順では、本文の読み込みも、シグナルの理由をエラーにして止めることになっています。検証環境の Chromium 153 の動きは、これと違っていました。

ポイント

止まった理由は、catch のエラーではなく、fetch に渡したシグナルの signal.reason から読みます。シグナルが中止されていないとき(ネットワークのエラーや、res.ok でないときに投げたエラー)だけ、err を使います。

フックの catch を signal.reason から読む形に直したのが、最初に載せたコードです。同じ API で試すと、本文の途中で止まったときも「3秒たっても終わらないため、取得をやめました」と表示されました。

text
--- 4. ヘッダーはすぐ返り、本文の残りに5秒かかる API
  3036ms 後: 3秒たっても終わらないため、取得をやめました
  サーバー:     0ms /api/slow-body 受信
  サーバー:     0ms /api/slow-body ヘッダーと本文の最初を送った
  サーバー:  2999ms /api/slow-body 送り終える前に接続が切れた

時間切れは、fetch() が解決するまでではなく、本文を読み終えるまでの全体にかかっていることも、この結果から分かります。

取得中にもう一度押したときと、コンポーネントが消えたとき

取得中に「取得する」をもう一度押すと、start() の最初で、前の AbortController の abort() を呼びます。前のリクエストの catch では、controllerRef.current がもう新しい AbortController に替わっているので、何もせずに戻ります。1秒かかる API で0.5秒後にもう一度押し、2.5秒の間、50ミリ秒ごとに表示を読みました。「取得を中止しました」は一度も出ず、前のリクエストだけが切れて、新しいリクエストの結果が表示されました。

text
--- 5. 1秒かかる API で、0.5秒後にもう一度「取得する」を押す
  2.5秒の間に出た表示: ["取得しています…","取得しました:たま(3歳)"]
  サーバー:     0ms /api/cat?delay=1000 受信
  サーバー:     0ms /api/cat?delay=1000 受信
  サーバー:   544ms /api/cat?delay=1000 送り終える前に接続が切れた
  サーバー:  1001ms /api/cat?delay=1000 応答を送り終えた

コンポーネントが画面から消えるときは、useEffect の片付け関数で abort() を呼びます。取得中に「表示する」のチェックを外してコンポーネントを消したところ、その時点でサーバーへの接続が切れました。ブラウザのコンソールを、React DevTools の案内を除いて数えると、警告やエラーは0件でした。

text
--- 6. 取得中に「表示する」を外して、コンポーネントを消す
  コンポーネントの数: 0
  サーバー:     0ms /api/cat?delay=5000 受信
  サーバー:  1044ms /api/cat?delay=5000 送り終える前に接続が切れた

なお、先ほどの MDN(英語版)の AbortSignal.timeout() の説明によると、AbortSignal.timeout() の時間は実際に動いていた時間で数えられ、ページが bfcache に入っている間などは止まります。また、時間切れのタイマーは途中で取り消せないので、タイマーを確実に止めたいときは AbortController と setTimeout()・clearTimeout() を組み合わせる方法が紹介されています。

​Posted by 86