EY-Office ブログ

今更ながらAbortControllerを学びました

今までに作ってきたReactアプリでは通信を中断する機能を要求されたことがなく、つい最近までfetch等の通信を中断できるAbortControllerを知りませんでした。
AbortControllerは2020年ころから、ほとんどのブラウザーでサポートされているそうなので、使い方を学んでみる事にしました。

AbortController Bing Image Creatorが生成した画像を使っています

今回のアプリ

今回作ったアプリは以下のアニメーションGIFのように、起動後(リロード後)にサーバーからデータを取得し表示する簡単なReactアプリです。サーバーからのデータ読み込み中にはグルグル回転するローディングアイコンが表示されています。データはTanStack Table紹介ブログで使っていたものです。

サーバー側はでは、①②のように処理を遅らせるタイマー処理が入っています。

import express, {Request, Response, NextFunction} from 'express';
import cors from 'cors';
import wines from './wine';

const SERVER_PORT = 3030;
const ALLOW_ORIGINS = ['http://localhost:3000', 'http://localhost:5173'];

const delay = (mSec: number) => new Promise(
  (resolve) => setTimeout(resolve, mSec));    // ← ①

const logger = (req: Request, res: Response, next: NextFunction) => {
  console.log(`${(new Date()).toISOString()} : ${req.method} ${req.url}`);
  next();
};

const app = express();
app.use(express.json());
app.use(cors({origin: ALLOW_ORIGINS}));
app.use(logger);

app.get('/wines', async (_req, res) => {
  await delay(5000);                         // ← ②
  res.json(wines);
});

app.listen(SERVER_PORT, (error) => {
  if (error) throw error;
  console.log(`Start server on port:${SERVER_PORT}`);
});

AbortController

このアプリでは以下のアニメーションGIFのように、ローディングアイコンの右にStopボタンがあり、これを押すと通信を中断します。

さて、コードは以下のようになります。AbortControllerの説明はMDNのAbortControllerにあり、それほど難しなくReactにも組み込めます。

  • ① AbortControllerのインスタンスを作り広域変数に代入しています
    • このインスタンスcontrollerが処理の中断を行う役割を担っています
    • Reactでコンポーネントはいろいろなタイミングで再描画されるので、ここでは広域変数に代入しています
  • ② 通信を行うfetchメソッドの第2引数signalオプションにcontroller.signalの値を指定することで、fetchを中断出来るようになります
  • controller.abort()を呼び出すと中断が起こります
    • 既に中断済みの時にcontroller.abort()を呼び出してもエラーにはなりません
    • 既に中断済みかはcontroller.signal.abortedの値で確認できます
  • controller.abort()が実行されると、fetchはAbortErrorという例外で終了します
    • ここではログを出しているだけですが、中断時の処理を組み込む場合は中断をステート管理し、ここでステートを変更すると良いかもしれませんね
  • ⑤ このコンポーネントが画面から消える際に通信を中断しています
    • useEffectの第1引数の関数の戻り値が関数の場合、コンポーネントが画面から消える際に、この関数が実行されます
    • 既に中断済み、または通信が開始されてない状態でcontroller.abort()を呼び出してもエラーにはなりません
import { useEffect, useState } from 'react';

type Wine = {
  country: string,
  variety: string,
  winery: string,
  title: string
};

const controller = new AbortController();                            // ← ①

function App() {
  const [wines, setWines] = useState<Wine[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    (async () => {
      try {
        setLoading(true);
        const response = await fetch('http://localhost:3030/wines',
          { signal: controller.signal });                           // ← ②
        const data = await response.json() as Wine[];
        setWines(data);
        setLoading(false);
      } catch (err) {
        if (err.name === 'AbortError') {        // ← ④
          console.log('fetch aborted');
        } else {
          console.log('fetch error', err);
        }
        setLoading(false);
      }
      return () => {                                                // ← ⑤
        controller.abort();
      }
    })()
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div className="m-6">
      <h1 className="my-6 text-center text-xl font-bold">Wines</h1>
      {loading ?
        <Spinner onStop={() => controller.abort()} /> :           // ← ③
        <List wines={wines} />}
    </div>
  )
}

function List({ wines }: { wines: Wine[] }) {
  return (
    <table className="w-full text-sm text-left text-gray-500">
      <thead className="bg-slate-100 border">
        <tr>
          <th className="px-6 py-3">Country</th>
          <th className="px-6 py-3">Variety</th>
          <th className="px-6 py-3">Winery</th>
          <th className="px-6 py-3">Title</th>
        </tr>
      </thead>
      <tbody className="bg-white border">
        {wines.map((wine, ix) => (
          <tr className="bg-white border-b" key={ix}>
            <td className="px-2 py-4">{wine.country}</td>
            <td className="px-2 py-4">{wine.variety}</td>
            <td className="px-2 py-4">{wine.winery}</td>
            <td className="px-2 py-4">{wine.title}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

function Spinner({onStop}: {onStop: () => void}) {
  return (
    <div className="flex justify-center" aria-label="Loading...">
      <button onClick={onStop} className="text-red-700 hover:text-white border border-red-700 hover:bg-red-800 font-medium rounded-lg text-sm px-5 me-4 text-center">Stop</button>
      <div className="animate-spin size-20 border-8 border-gray-200 rounded-full border-t-rose-600" />
    </div>
  );
}

export default App

Susupenceを使った通信でのAbortController

最初のコードは、古典的と言えるようなuseStateで通信データや読み込み中を管理し、useEffetctで通信を起動していますが、今時はSuspenseを使いますよね。😄

Suspenseを使うには通信はfetchではなく、Suspenseに対応したTanStack Query (旧 React Query)SWRを使う事が多いと思います。そこでTanStack Queryで試してみたのですが上手く行きませんでした。 よくドキュメントを読むと Cancellation does not work when working with Suspense hooks: useSuspenseQuery,...と書かれていました。😅

そこで、このブログを参考にSuspenseに対応した通信関数を使い、Susupence版を作ってみました。

  • ① 上のコードと同じですね、AbortControllerのインスタンスを作り広域変数に代入しています
  • ② AppコンポーネントはSuspenseを使ったコードになっています
  • ③ createResourceはブログにあるコードです。簡単に解説すると
    • この関数はSusupeceに対応したreadメソッドも持つオブジェクトを戻します
    • Suspenseに対応した関数・メソッドは、最初の呼出しでは通信処理を含むPromiseを例外としてThrowします。Throwされた例外は<Suspense>が捕まえ通信処理を起動し、通信状態も管理し通信中はfallbackで指定したコンポーネント(JSX)を実行します
    • 通信処理を含むPromise内では、通信が完了すると受信データを戻します、またエラーが発生すると例外をThrowします
    • 以上を行うためにstatus変数で状態を管理しています
  • ④ 変数wineResourceはcreateResourceを使いワインデータを取得するreadメソッドも持つオブジェクトが代入されてます
    • createResourceに渡すfetch関数では、上のコードと同じくsignalを渡しています
    • またエラーが起きた場合、エラーが原因が中断処理AbortErrorの場合は空配列を受信データとして戻しています。それ以外のエラーはThrowしています
  • wineResource.read()を呼出すことでSuspenseに対応した通信を行っています

Suspenceに対応した通信処理を作る部分があり複雑になっていますが、中断処理は上のコードと同じですね。

import { Suspense} from 'react'

type Wine = {
  country: string,
  variety: string,
  winery: string,
  title: string
};

const controller = new AbortController();                            // ← ①

const createResource = (fetchFn: () => Promise<unknown>) => {        // ← ③
  let status = "pending";
  let result: unknown;

  const promise = fetchFn().then(
    (data) => {
      status = "success";
      result = data;
    },
    (error) => {
      status = "error";
      result = error;
    }
  );
  return {
    read() {
      if (status === "pending") throw promise;   // Suspense fallback
      if (status === "error") throw result;      // Goes to error boundary
      return result;
    }
  };
}

const wineResource = createResource(() =>                             // ← ④
  fetch('http://localhost:3030/wines', { signal: controller.signal })
    .then((res) => res.json())
    .catch((err) => {
      if (err.name === 'AbortError') {
        console.log('fetch aborted');
        return [];
      } else {
        console.log('fetch error', err);
        throw err;
      }
    })
);

function App() {                                                    // ← ②
  return (
    <div className="m-6">
      <h1 className="my-6 text-center text-xl font-bold">Wines</h1>
      <Suspense fallback={<Spinner onCancel={() => controller.abort()}/>}>
        <List />
      </Suspense>
    </div>
  )
}

function List() {
  const wines = wineResource.read() as Wine[];                    // ← ⑤

  return (
    <table className="w-full text-sm text-left text-gray-500">
      <thead className="bg-slate-100 border">
        <tr>
          <th className="px-6 py-3">Country</th>
          <th className="px-6 py-3">Variety</th>
          <th className="px-6 py-3">Winery</th>
          <th className="px-6 py-3">Title</th>
        </tr>
      </thead>
      <tbody className="bg-white border">
        {wines.map((wine, ix) => (
          <tr className="bg-white border-b" key={ix}>
            <td className="px-2 py-4">{wine.country}</td>
            <td className="px-2 py-4">{wine.variety}</td>
            <td className="px-2 py-4">{wine.winery}</td>
            <td className="px-2 py-4">{wine.title}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

function Spinner({onCancel}: {onCancel: () => void}) {
  return (
    <div className="flex justify-center" aria-label="Loading...">
      <button onClick={onCancel} className="text-red-700 hover:text-white border border-red-700 hover:bg-red-800 font-medium rounded-lg text-sm px-5 me-4 text-center">Stop</button>
      <div className="animate-spin size-20 border-8 border-gray-200 rounded-full border-t-rose-600" />
    </div>
  );
}

export default App

まとめ

fetch等の通信を中断できるAbortControllerを学んでみました。コードで説明した通り簡単に中断できる事がわかりました。

注意点は、new AbortController()で作ったAbortControllerのインスタンスがfetchの中断と、中断を起こすabort()をつなぐインスタンスなので、fetchで参照するsignalabort()で同じインスタンスを参照する事でしょうか。とくにReactではコンポーネントの再描画=再実行が起きるので注意してください。今回は広域変数にインスタンスを置きましたが、useRef等を使っても良いのかもしれませんね。

今回、TanStack Query (旧 React Query)を使ったAbortControllerも試したのですが、Query Cancellationドキュメント通りに作っているつもりでしたが上手く行きませんでした。なぜでしょうね?

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ