Reactで、fetchに時間切れと中止ボタンを両方付ける方法
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 です。
これを使って、取得の状態を返すカスタムフックを書きました。
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 を入れたふつうの設定のまま、型チェックが通りました。
ボタンに組み込む
フックを使うコンポーネントです。中止ボタンは、取得中だけ押せるようにしています。
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秒の時間切れで止まりました。

時間切れと中止ボタンで、サーバーとの接続まで切れるか
Playwright で Chromium を操作し、画面の表示と、サーバーが記録した時刻(リクエストを受けてからのミリ秒)を並べました。まず、1秒で返る API です。時間切れの3秒より早いので、そのまま取得できました。
--- 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秒後に押したときは、その時点で止まりました。どちらも、サーバー側で「送り終える前に接続が切れた」と記録されており、ブラウザの中で結果を捨てただけではなく、通信そのものが切れています。
--- 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 の中だけをこの書き方にしたフックも用意しました。
if (err instanceof DOMException && err.name === 'TimeoutError') {
setState({ status: 'timeout' });
} else if (err instanceof DOMException && err.name === 'AbortError') {
setState({ status: 'canceled' });
応答のヘッダーと本文の最初はすぐに返し、本文の残りを5秒後に返す API を用意して、この書き方で試しました。3秒で止まったのに、表示は「取得を中止しました」でした。
--- 0b. ヘッダーはすぐ返り、本文の残りに5秒かかる API
3047ms 後: 取得を中止しました
サーバー: 0ms /api/slow-body 受信
サーバー: 0ms /api/slow-body ヘッダーと本文の最初を送った
サーバー: 3000ms /api/slow-body 送り終える前に接続が切れた
fetch() そのものは、ヘッダーが届いた時点で解決しています。止まったのは、その後の res.text() です。時間切れを0.5秒にして、ブラウザの中で同じ状況を作り、res.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秒たっても終わらないため、取得をやめました」と表示されました。
--- 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ミリ秒ごとに表示を読みました。「取得を中止しました」は一度も出ず、前のリクエストだけが切れて、新しいリクエストの結果が表示されました。
--- 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件でした。
--- 6. 取得中に「表示する」を外して、コンポーネントを消す
コンポーネントの数: 0
サーバー: 0ms /api/cat?delay=5000 受信
サーバー: 1044ms /api/cat?delay=5000 送り終える前に接続が切れた
なお、先ほどの MDN(英語版)の AbortSignal.timeout() の説明によると、AbortSignal.timeout() の時間は実際に動いていた時間で数えられ、ページが bfcache に入っている間などは止まります。また、時間切れのタイマーは途中で取り消せないので、タイマーを確実に止めたいときは AbortController と setTimeout()・clearTimeout() を組み合わせる方法が紹介されています。





