検索ボックスで文字を打つたびに API を呼んでいたら、古いリクエストの結果が後から届いて表示が上書きされてしまった——fetch() を使っていると、「もう要らなくなったリクエストを止めたい」場面が必ず出てきます。JavaScript で非同期処理を途中で中断するための標準の仕組みが AbortController です。この記事では、fetch に signal を渡して中断する基本形から、AbortError の扱い、AbortSignal.timeout() によるタイムアウト、検索ボックスでの実践例までを解説します。
目次
AbortController は非同期処理に「中断ボタン」を後付けする仕組み
fetch() が返す Promise には、それ自体をキャンセルする機能がありません。一度リクエストを送ってしまうと、応答が返るか失敗するまで待つしかないのが素の状態です。そこで用意されているのが AbortController で、「中断ボタン」と「そのボタンが押されたことを伝える信号」をセットで発行してくれるオブジェクトだとイメージすると分かりやすいです。
使い方はシンプルで、new AbortController() で作ったインスタンスから2つのものを使います。
| メンバー | 役割 |
|---|---|
controller.signal | AbortSignal オブジェクト。中断したい処理(fetch など)に渡しておく |
controller.abort() | 中断を実行するメソッド。呼ぶと signal を渡した処理が中断される |
つまり「signal を渡しておき、止めたくなったら abort() を呼ぶ」という2ステップだけです。signal 側にも状態を確認するためのプロパティがあり、signal.aborted で中断済みかどうかを真偽値で調べられ、signal.reason で中断の理由を取得できます。
fetch に signal を渡して中断する基本形
fetch() の第2引数のオプションに signal を渡しておくと、あとから controller.abort() を呼んだ時点でリクエストが中断されます。まずは最小の形を見てみましょう。
const controller = new AbortController();
// オプションの signal に controller.signal を渡しておく
fetch('/api/data', { signal: controller.signal })
.then((res) => res.json())
.then((data) => console.log(data));
// 中断したいタイミングで abort() を呼ぶ
// (例:キャンセルボタンが押されたとき)
controller.abort();
abort() が呼ばれると、fetch() が返していた Promise は拒否(reject)されます。通信がすでに完了したあとに abort() を呼んでも何も起きないので、「間に合ったら中断される」くらいの気持ちで呼んで問題ありません。逆に、すでに中断済みの signal を渡して fetch() を呼んだ場合は、リクエストを送らずに即座に拒否されます。
1つ注意したいのは、AbortController は使い捨てだという点です。一度 abort() した controller の signal は中断済みのままなので、次のリクエストを送るときは新しい AbortController を作り直します。
中断時に投げられる AbortError の扱い
abort() で中断された fetch() の Promise は、name が "AbortError" の DOMException で拒否されます。async / await で書いている場合は catch に流れてくるため、そのままにするとネットワークエラーと同じ扱いになってしまいます。意図した中断なのか、本当の失敗なのかは err.name で見分けます。
const controller = new AbortController();
async function loadData() {
try {
const res = await fetch('/api/data', { signal: controller.signal });
const data = await res.json();
console.log(data);
} catch (err) {
if (err.name === 'AbortError') {
// abort() による中断。こちらから止めたのでエラー表示は不要
console.log('リクエストを中断しました');
} else {
// ネットワークエラーなど、それ以外の失敗
console.error('通信に失敗しました:', err);
}
}
}
中断は自分で指示した結果なので、ユーザーにエラーメッセージを見せる必要はないことがほとんどです。「AbortError なら黙って無視し、それ以外はエラーとして処理する」というのが定番の書き方になります。
なお、abort() には引数で中断理由を渡すこともできます。controller.abort(new Error('画面遷移のため中断')) のように呼ぶと、Promise はその値で拒否され、signal.reason からも同じ値を参照できます。引数を省略したときの既定が、先ほどの AbortError という名前の DOMException です。
AbortSignal.timeout() でタイムアウトを実装する
「一定時間応答がなければ諦める」というタイムアウト処理は、AbortController を自分で用意しなくても、静的メソッドの AbortSignal.timeout() で実現できます。ミリ秒を渡すと、その時間が経過した時点で自動的に中断される signal が返ってくるので、fetch() に渡すだけです。
async function loadWithTimeout() {
try {
const res = await fetch('/api/data', {
// 5000 ミリ秒(5秒)で自動的に中断される signal を渡す
signal: AbortSignal.timeout(5000),
});
const data = await res.json();
console.log(data);
} catch (err) {
if (err.name === 'TimeoutError') {
console.log('5秒以内に応答がありませんでした');
} else {
console.error('通信に失敗しました:', err);
}
}
}
ここで注意したいのが、エラーの名前です。abort() による中断が "AbortError" だったのに対し、AbortSignal.timeout() による中断では name が "TimeoutError" の DOMException で拒否されます。時間切れをユーザーに知らせたい場合は、err.name === 'TimeoutError' のほうで判定してください。
AbortSignal.any() で複数のシグナルを合成する
「ユーザーのキャンセル操作でも止めたいし、タイムアウトでも止めたい」というように、中断のきっかけが複数あるケースでは AbortSignal.any() を使います。signal の配列を渡すと、そのうちどれか1つでも中断されたら中断される合成 signal が返ってきます。
const controller = new AbortController();
const res = await fetch('/api/data', {
signal: AbortSignal.any([
controller.signal, // キャンセルボタンなど、手動の中断
AbortSignal.timeout(5000), // 5秒のタイムアウト
]),
});
合成 signal が中断されたとき、拒否の理由には「最初に中断された signal の理由」がそのまま使われます。上の例なら、手動の abort() が先なら AbortError、時間切れが先なら TimeoutError になるので、catch での判定方法はこれまでと変わりません。
実践例:検索ボックスの連続入力で古いリクエストを中断する
AbortController がもっとも活躍する場面のひとつが、インクリメンタルサーチ(入力のたびに検索結果を更新する UI)です。ユーザーが「abc」と素早く打つと「a」「ab」「abc」の3回のリクエストが飛びますが、応答が返る順序はネットワーク次第です。もし「abc」の結果より後に「a」の結果が届くと、古い結果で画面が上書きされてしまいます。
そこで、新しい入力が来たら前回のリクエストを abort() で中断してから、新しいリクエストを送るようにします。こうすれば「最後の入力に対する結果」だけが画面に反映されます。
const input = document.querySelector('#search-input');
const result = document.querySelector('#search-result');
// 直前のリクエストの controller を覚えておく変数
let controller = null;
input.addEventListener('input', async () => {
// 前回のリクエストが残っていれば中断する
if (controller) {
controller.abort();
}
// AbortController は使い捨てなので、リクエストごとに作り直す
controller = new AbortController();
try {
const query = encodeURIComponent(input.value);
const res = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
const items = await res.json();
result.textContent = `${items.length} 件ヒットしました`;
} catch (err) {
// 中断は「新しい入力に置き換わった」だけなので何もしない
if (err.name !== 'AbortError') {
console.error('検索に失敗しました:', err);
}
}
});
ポイントは、controller を変数に保持しておき、「前回分を中断してから、新しい controller に差し替える」という流れです。中断された側の処理は catch に AbortError として流れてきますが、これは想定どおりの動きなので握りつぶして問題ありません。
1つ補足すると、abort() で止められるのは「ブラウザー側の処理」です。リクエストがサーバーに届いたあとであれば、サーバー側の処理自体は実行されている可能性があります。データ取得(GET)なら気にする必要はほぼありませんが、登録や更新系のリクエストでは「中断=サーバーで何も起きていない」とは限らない点だけ頭に入れておいてください。
fetch 以外の使いどころ:イベントリスナーをまとめて解除する
AbortController は fetch 専用の仕組みではなく、AbortSignal を受け取れる API なら同じように使えます。中でも便利なのが addEventListener() の signal オプションです。リスナーの登録時に signal を渡しておくと、abort() を呼んだ時点でそのリスナーが自動的に解除されます。
const controller = new AbortController();
// 登録時に signal オプションを渡しておく
window.addEventListener('scroll', onScroll, { signal: controller.signal });
window.addEventListener('resize', onResize, { signal: controller.signal });
document.addEventListener('click', onClick, { signal: controller.signal });
// abort() を1回呼ぶだけで、上の3つのリスナーがまとめて解除される
controller.abort();
removeEventListener() で解除するには、登録時と同じ関数への参照を保持しておく必要があり、リスナーが増えるほど管理が面倒になります。signal 方式なら controller を1つ持っておくだけでよく、モーダルを閉じるときや SPA でコンポーネントを破棄するときの「後片付け」を1行で済ませられます。
ブラウザーの対応状況
AbortController と fetch の signal オプション自体は 2019 年までに主要ブラウザーが対応を終えており、現在は問題なく利用できます。addEventListener の signal オプションは Chrome 90・Firefox 86・Safari 15(いずれも 2021 年)から利用可能です。
比較的新しいのは静的メソッドの2つで、AbortSignal.timeout() は Chrome 103・Firefox 100・Safari 16(2022 年)、AbortSignal.any() は Chrome 116(2023 年)・Safari 17.4・Firefox 124(2024 年)からの対応です。とはいえどちらも現行の主要ブラウザーには行き渡っているため、特別に古い環境をサポートする場合を除けば安心して使えます。
まとめ
AbortController は、fetch() などの非同期処理に中断機能を後付けする仕組みです。controller.signal を処理に渡しておき、止めたいタイミングで controller.abort() を呼ぶ——この2ステップがすべてで、中断された Promise は AbortError(AbortSignal.timeout() なら TimeoutError)で拒否されるため、err.name で意図した中断と本当のエラーを見分けます。
タイムアウトだけなら AbortSignal.timeout()、中断のきっかけが複数あるなら AbortSignal.any() と、用途に応じた省略記法も揃っています。検索ボックスの古いリクエストのキャンセルや、イベントリスナーの一括解除など、実務で使える場面は多いので、ぜひ手元のコードで試してみてください。