今までに作ってきたReactアプリでは通信を中断する機能を要求されたことがなく、つい最近までfetch等の通信を中断できるAbortControllerを知りませんでした。
AbortControllerは2020年ころから、ほとんどのブラウザーでサポートされているそうなので、使い方を学んでみる事にしました。
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で参照するsignal
とabort()
で同じインスタンスを参照する事でしょうか。とくにReactではコンポーネントの再描画=再実行が起きるので注意してください。今回は広域変数にインスタンスを置きましたが、useRef
等を使っても良いのかもしれませんね。
今回、TanStack Query (旧 React Query)を使ったAbortControllerも試したのですが、Query Cancellationドキュメント通りに作っているつもりでしたが上手く行きませんでした。なぜでしょうね?