Remix 3 から学ぶ AbortControllerの使い方
はじめに
これは株式会社TimeTree Advent Calendar 2025の7日目の記事です。
おはようございます。TimeTree というカレンダーアプリのウェブ版を開発している Saul(ソール)です。
今回は AbortController について書いていきます。
きっかけ
2025年10月に開催された Remix Jam 2025 にて、Remix 3 のお披露目がありました。
Web標準に振り切った大胆な変化に驚きつつ、その中で特に気になったのが 「関数を渡したら、signal を返す」 という仕組み。AbortController を使ってライフサイクルを表現するといった話もあり、「そんなことできるの?」と気になり、今回調べてみました。
Remix 3 での signal の特徴
- 🛠️ 自動キャンセル
- 非同期処理に
signalを渡すだけで完了
- 非同期処理に
- 🔄 ライフサイクル連動
- コンポーネント破棄時に
this.signalが自動abort
- コンポーネント破棄時に
- ⚡ イベント制御
- 連打しても最新以外は自動でキャンセル
あまり触れる機会もなかったのですが、これを機に AbortController 自体の理解を深めつつ、React アプリケーションでどう活かせるか考えてみます。
対象読者
- AbortController を聞いたことはあるが、あまり使ったことがない人
- Fetch のキャンセル処理を実装したことがあるが、他の用途を知りたい人
- Remix 3 の設計思想に興味がある React エンジニア
前半はAbortController の基本をまとめています。それくらい知ってるよーという方は飛ばしてくださいませ。
AbortController とは?
まずは MDN の定義を引用します。
AbortControllerインターフェイスは 1 つ以上のウェブリクエストをいつでも中断することを可能にするコントローラーオブジェクトを表します。
シンプルなコード例を見てみましょう。
const controller = new AbortController();
const signal = controller.signal;
fetch("/api/data", { signal })
.then((res) => res.json())
.then((data) => console.log(data))
.catch((err) => {
if (err.name === "AbortError") {
console.log("リクエストがキャンセルされました");
}
});
// 何らかの理由でキャンセルしたいとき
controller.abort();
AbortController を作成し、その signal を fetch に渡す。キャンセルしたいときは abort() を呼ぶ。これが基本形です。
AbortController の仕組み
Controller と signal の関係
AbortController は Controller と signal の 2 つで構成されています。
const controller = new AbortController();
const signal = controller.signal;
-
Controller:
abort()を呼ぶ側。キャンセルの「トリガー」 - signal: キャンセルされたかどうかを持つオブジェクト。非同期処理に渡す
controller.abort() が呼ばれると、signal の aborted プロパティが true になり、abort イベントが発火します。
const controller = new AbortController();
controller.signal.addEventListener("abort", () => {
console.log("キャンセルされました");
});
controller.abort(); // → "キャンセルされました"
console.log(controller.signal.aborted); // → true
参考: AbortSignal - Web API | MDN
また、今回は触れませんが signal を受け取れる API は徐々に増えているようです。
-
fetch(url, { signal })— HTTP リクエスト -
addEventListener(type, listener, { signal })— イベントリスナー -
ReadableStream.prototype.pipeTo(writable, { signal })— Streams API -
navigator.locks.request(name, { signal }, callback)— Web Locks API -
navigator.credentials.create({ signal })/get({ signal })— WebAuthn -
NavigateEvent.signal— Navigation API
Remix 3 のアプローチ — signal を中心にした設計
Remix 3 では、フレームワークレベルで AbortSignal が統合されています。開発者は意識せずとも、多くの場面で signal を受け取ることができます。
1. イベントハンドラの signal
イベントが発生するたびに新しい signal が渡されます。次のイベントが発生すると、前の signal は自動で abort されます。
<button on={dom.click((e, signal) => {
// 2回目がクリックされると、
// 1回目のsignalはabort済みになる
await fetch('/api', { signal });
})} />
これにより、「連打」による Race Condition を防げます。
2. コンポーネントの signal(this.signal)
コンポーネントのライフサイクルに紐づく signal も提供されます。アンマウント時に自動で abort されるので、React の useEffect クリーンアップに相当する処理が不要になります。
class MyComponent extends Component {
async load() {
// this.signal はコンポーネント生存中のみ有効
// アンマウント時に自動で abort される
const response = await fetch('/api/data', { signal: this.signal });
if (this.signal.aborted) return;
this.data = await response.json();
}
}
React で実践する
Remix 3 のような自動化はありませんが、React でも AbortController を活用できます。
基本編: Fetch のキャンセル
useEffect を使って「コンポーネントのライフサイクル」と「signal」を紐付けます。
useEffect(() => {
// 1. コントローラーを作成
const controller = new AbortController();
// 2. signalをfetchに渡す
fetch("/api/data", { signal: controller.signal })
.then((res) => res.json())
.then((data) => setData(data))
.catch((err) => {
// AbortErrorは無視するのが通例
if (err.name !== "AbortError") console.error(err);
});
// 3. クリーンアップでabort()を呼ぶ
return () => controller.abort();
}, []);
コンポーネントがアンマウントされたり、依存配列の値が変わったりすると、クリーンアップ関数が実行されて fetch がキャンセルされます。
TanStack Query なら自動で signal が渡される
TanStack Queryを使っている場合、各クエリ関数に signal が自動で渡されます。Remix 3 に近い体験が得られるのでおすすめです。おそらく他のライブラリでも同様の仕組みがあると思います。
const { data } = useQuery({
queryKey: ["todos"],
queryFn: async ({ signal }) => {
const response = await fetch("/api/todos", { signal });
return response.json();
},
});
参考:TanStack Query - Query Cancellation
応用編: イベントリスナーのクリーンアップ
addEventListener の第 3 引数に { signal } を渡すと、abort() 時に自動的にリスナーが外れます。複数のリスナーを登録する場合、特に効果的です。
参考: EventTarget.addEventListener() - Web API | MDN
従来の方法
useEffect(() => {
const handleScroll = (event) => {
/* 略 */
};
document.addEventListener("scroll", handleScroll);
document.addEventListener("resize", handleScroll);
return () => {
// 登録した数だけ removeEventListener が必要
// 同じ関数参照を渡す必要もある
document.removeEventListener("scroll", handleScroll);
document.removeEventListener("resize", handleScroll);
};
}, []);
AbortController を使う
useEffect(() => {
const controller = new AbortController();
const handleScroll = (event) => {
/* 略 */
};
// 同じsignalを渡す
const opts = { signal: controller.signal };
document.addEventListener("scroll", handleScroll, opts);
document.addEventListener("resize", handleScroll, opts);
// 1行で全解除
return () => controller.abort();
}, []);
1 つの abort() ですべてのリスナーが外れます。「コンポーネントの寿命 = signal の寿命」と考えると管理しやすいです。
発展編: Race Condition 対策
検索ボックスやタブ切り替えなど、「最新の結果だけが欲しい」場面での実装です。
useRef でコントローラーを保持し、「次を始める前に前を消す」パターンを使います。
function CitySelector() {
const [cities, setCities] = useState<string[]>([]);
// コントローラーを保持するRef
const abortRef = useRef<AbortController | null>(null);
const handleChange = async (e: ChangeEvent<HTMLSelectElement>) => {
// 1. 前回のリクエストをキャンセル
abortRef.current?.abort();
// 2. 新しいコントローラーを作成・保持
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch(`/api/cities?state=${e.target.value}`, {
signal: controller.signal, // 3. signalを渡す
});
// 4. fetch完了後でもabortされていたら処理しない
if (controller.signal.aborted) return;
const data = await res.json();
setCities(data);
} catch (err) {
if (err.name !== "AbortError") {
/* エラーハンドリング */
}
}
};
return <select onChange={handleChange}>...</select>;
}
ちなみに Remix 3 では signal や Ref の処理をフレームワークが隠蔽してくれます。
<select
on={dom.change(async (event, signal) => {
// 開発者は signal を渡すだけ
const response = await fetch(`/api/cities?state=${event.target.value}`, {
signal,
});
if (signal.aborted) return;
cities = await response.json();
})}
/>
Remix 3 は「イベントの発火」と「signal の寿命」をリンクさせています。React で実装する場合も、この関係性を意識すると設計が見えやすくなります。
まとめ
-
Remix 3 は Web 標準(AbortSignal)をフレームワークの基盤に据えた
「とりあえず signal を渡す」だけで安全になる設計は、開発者体験として優れている -
React でも AbortController を使いこなせば、非同期処理の制御が堅牢になる
useEffectのクリーンアップと組み合わせるのが基本パターン -
「非同期処理には signal を渡す」を習慣にしよう
Fetch 以外にもaddEventListenerなど、活用できる場面は多い -
TanStack Query を使っている場合は、すでに signal が自動提供されている
まだ活用していなければ、クエリ関数で受け取ってみると良い
以上、本記事がどなたかの参考になれば幸いです。
参考リンク
TimeTreeのエンジニアによる記事です。メンバーのインタビューはこちらで発信中! note.com/timetree_inc/m/m4735531db852
Discussion