zukucode
主にWEB関連の情報を技術メモとして発信しています。

React TanStack Queryでバックグラウンド処理をポーリングする

CSVファイルのインポートなど、完了まで時間がかかる処理をHTTPリクエストの中ですべて実行すると、タイムアウトする可能性があります。

このような処理では、開始APIから処理IDを返し、ブラウザから状態取得APIを定期的に呼び出す方法があります。

今回はTanStack Queryを使用して、CSVインポートが完了するまでポーリングする方法を紹介します。

APIの流れ

以下の2つのAPIを使用します。

API処理
POST /api/importsCSVファイルを受け取り、処理IDを返す
GET /api/imports/{id}現在の処理状況を返す

開始APIは重い処理の完了を待たず、バックグラウンド処理を登録した時点でレスポンスを返します。

画面は返された処理IDを使用して、完了または失敗するまで状態を確認します。

レスポンスの型を定義する

開始APIと状態取得APIのレスポンスを定義します。

types.ts
export 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.ts
import 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から受け取るAbortSignalfetchへ渡せるようにしています。

状態取得用のHookを作成する

処理IDがある場合だけ状態取得を実行します。

useImportStatus.ts
import { 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を受け取る関数を指定できます。

まだ結果がない場合、または状態がwaitingprocessingの場合は1秒後に再取得します。completedまたはfailedになった場合はfalseを返し、ポーリングを停止します。

アップロード画面を作成する

開始処理にはuseMutation、状態取得には先ほど作成したuseImportStatusを使用します。

CsvImport.tsx
import { 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とサーバー側のキャンセル処理を別途実装します。

通信エラー時の再試行を設定する

一時的な通信エラーだけ再試行する場合は、retryretryDelayを設定します。

再試行の設定例
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を呼ばない
開始APIが成功処理IDを使ってポーリングを始める
waitingまたはprocessing1秒後に状態を再取得する
completed登録件数を表示してポーリングを停止する
failedエラーを表示してポーリングを停止する
別のファイルを選択前回の処理結果を消す
画面から離れる実行中の状態取得リクエストを中断する

enabledで開始条件を、refetchIntervalで終了条件を表すことで、タイマーを直接管理せずにポーリングを実装できます。


関連記事