📘

Remix 3 から学ぶ AbortControllerの使い方

に公開

はじめに

これは株式会社TimeTree Advent Calendar 2025の7日目の記事です。
おはようございます。TimeTree というカレンダーアプリのウェブ版を開発している Saul(ソール)です。

今回は AbortController について書いていきます。

きっかけ

2025年10月に開催された Remix Jam 2025 にて、Remix 3 のお披露目がありました。
https://remix.run/blog/remix-jam-2025-recap

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 は Controllersignal の 2 つで構成されています。

const controller = new AbortController();
const signal = controller.signal;
  • Controller: abort() を呼ぶ側。キャンセルの「トリガー」
  • signal: キャンセルされたかどうかを持つオブジェクト。非同期処理に渡す

controller.abort() が呼ばれると、signalaborted プロパティが 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();
  }
}

参考: Remix 3 Resources

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 Tech Blog

Discussion