React TanStack Queryでバックグラウンド処理をポーリングする
CSVファイルのインポートなど、完了まで時間がかかる処理をHTTPリクエストの中ですべて実行すると、タイムアウトする可能性があります。
このような処理では、開始APIから処理IDを返し、ブラウザから状態取得APIを定期的に呼び出す方法があります。
今回はTanStack Queryを使用して、CSVインポートが完了するまでポーリングする方法を紹介します。
APIの流れ
以下の2つのAPIを使用します。
| API | 処理 |
|---|---|
POST /api/imports | CSVファイルを受け取り、処理IDを返す |
GET /api/imports/{id} | 現在の処理状況を返す |
開始APIは重い処理の完了を待たず、バックグラウンド処理を登録した時点でレスポンスを返します。
画面は返された処理IDを使用して、完了または失敗するまで状態を確認します。
レスポンスの型を定義する
開始APIと状態取得APIのレスポンスを定義します。
types.tsexport type StartImportResult = {
importId: string;
};
export type ImportStatus =
| { status: 'waiting'; progress: 0 }
| { status: 'processing'; progress: number }
| { status: 'completed'; progress: 100; importedCount: number }
| { status: 'failed'; progress: number; message: string };statusを判定すると、その状態で使用できる項目をTypeScriptが絞り込めます。
例えば、importedCountは処理が完了した場合だけ使用できます。
API関数を作成する
CSVファイルを送信する関数と、状態を取得する関数を作成します。
importApi.tsimport type { ImportStatus, StartImportResult } from './types';
const readJson = async <T>(response: Response): Promise<T> => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<T>;
};
export const startImport = async (file: File): Promise<StartImportResult> => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/imports', {
method: 'POST',
body: formData,
});
return readJson<StartImportResult>(response);
};
export const findImportStatus = async (
importId: string,
signal: AbortSignal,
): Promise<ImportStatus> => {
const response = await fetch(
`/api/imports/${encodeURIComponent(importId)}`,
{ signal },
);
return readJson<ImportStatus>(response);
};FormDataを送る場合は、Content-Typeヘッダーを手動で設定しません。ブラウザが境界文字列を含む正しいヘッダーを設定します。
状態取得では、TanStack Queryから受け取るAbortSignalをfetchへ渡せるようにしています。
状態取得用のHookを作成する
処理IDがある場合だけ状態取得を実行します。
useImportStatus.tsimport { useQuery } from '@tanstack/react-query';
import { findImportStatus } from './importApi';
export const useImportStatus = (importId: string | undefined) => {
return useQuery({
queryKey: ['csvImport', importId ?? ''],
enabled: importId !== undefined,
queryFn: ({ signal }) => {
if (!importId) throw new Error('importId is required.');
return findImportStatus(importId, signal);
},
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === 'completed' || status === 'failed' ? false : 1000;
},
refetchIntervalInBackground: false,
});
};enabledによって、ファイルを送信する前の状態取得を防ぎます。
refetchIntervalには固定値だけでなく、現在のQueryを受け取る関数を指定できます。
まだ結果がない場合、または状態がwaitingかprocessingの場合は1秒後に再取得します。completedまたはfailedになった場合はfalseを返し、ポーリングを停止します。
アップロード画面を作成する
開始処理にはuseMutation、状態取得には先ほど作成したuseImportStatusを使用します。
CsvImport.tsximport { useState, type ChangeEvent } from 'react';
import { useMutation } from '@tanstack/react-query';
import { startImport } from './importApi';
import { useImportStatus } from './useImportStatus';
export const CsvImport = () => {
const [file, setFile] = useState<File>();
const [importId, setImportId] = useState<string>();
const startMutation = useMutation({
mutationFn: startImport,
onSuccess: (result) => setImportId(result.importId),
});
const statusQuery = useImportStatus(importId);
const result = statusQuery.data;
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
setFile(event.target.files?.[0]);
setImportId(undefined);
};
const handleStart = () => {
if (!file) return;
setImportId(undefined);
startMutation.mutate(file);
};
return (
<section>
<h1>商品データをインポート</h1>
<input type="file" accept=".csv,text/csv" onChange={handleFileChange} />
<button
type="button"
disabled={!file || startMutation.isPending}
onClick={handleStart}
>
インポートを開始
</button>
{startMutation.isError && (
<p role="alert">ファイルの送信に失敗しました。</p>
)}
{statusQuery.isError && (
<p role="alert">処理状況を取得できませんでした。</p>
)}
{result?.status === 'waiting' && <p>処理を待っています。</p>}
{result?.status === 'processing' && (
<p role="status">処理中: {result.progress}%</p>
)}
{result?.status === 'completed' && (
<p role="status">{result.importedCount}件を登録しました。</p>
)}
{result?.status === 'failed' && (
<p role="alert">インポートに失敗しました: {result.message}</p>
)}
</section>
);
};ファイルを変更した場合は、前回のimportIdを消して以前の結果を表示しないようにしています。
開始ボタンはファイルが選択されていない場合と、開始APIの実行中に無効にします。
画面を離れた場合はリクエストを中断する
状態取得関数へAbortSignalを渡しているため、Queryが不要になると実行中のfetchを中断できます。
例えば、コンポーネントがアンマウントされた場合や、別のimportIdへquery keyが変わった場合です。
fetchの中断は、ブラウザが状態取得のレスポンスを待つことをやめる処理です。サーバー上のCSVインポートは停止しません。
利用者がインポート自体を取り消せるようにする場合は、取消APIとサーバー側のキャンセル処理を別途実装します。
通信エラー時の再試行を設定する
一時的な通信エラーだけ再試行する場合は、retryとretryDelayを設定します。
再試行の設定例retry: (failureCount, error) => {
if (error instanceof ApiError && error.status === 404) return false;
return failureCount < 3;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),404は処理IDが存在しないため再試行せず、通信エラーなどは最大3回まで間隔を延ばして再試行します。
上記のApiErrorを作成する方法は、以下の記事で紹介しています。
Problem Detailsを共通のApiErrorへ変換する方法
利用者が多い場合、1秒間隔でもサーバーへのリクエスト数が増えます。処理時間に応じて間隔を調整し、長時間かかる処理では徐々に間隔を延ばす方法も検討します。
処理の再実行に対応する
完了後に別のファイルを送信すると、新しいimportIdが返ります。
query keyにはimportIdが含まれているため、新しい処理の状態を前回とは別のQueryとして取得できます。
同じファイルの開始APIを再送すると処理が複数作成される場合があります。ボタンを無効にするだけで不十分な場合は、開始APIへ冪等性キーを追加します。
動作を確認する
| 状況 | 期待する結果 |
|---|---|
| ファイル選択前 | 開始ボタンが無効になり、状態取得APIを呼ばない |
| 開始APIが成功 | 処理IDを使ってポーリングを始める |
waitingまたはprocessing | 1秒後に状態を再取得する |
completed | 登録件数を表示してポーリングを停止する |
failed | エラーを表示してポーリングを停止する |
| 別のファイルを選択 | 前回の処理結果を消す |
| 画面から離れる | 実行中の状態取得リクエストを中断する |
enabledで開始条件を、refetchIntervalで終了条件を表すことで、タイマーを直接管理せずにポーリングを実装できます。