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

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

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

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

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

サーバーからの即時通知が必要ならSSEやWebSocketも候補になります。ここでは、数秒単位の遅延を許容でき、処理状態を取得するHTTP APIがすでにあるケースを対象にします。接続を維持する仕組みを追加せず、画面の再読み込み後も処理IDから状態を復元しやすいことがポーリングを選ぶ理由です。

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から受け取るAbortSignalをfetchへ渡せるようにしています。

状態取得用の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を受け取る関数を指定できます。

まだ結果がない場合、または状態がwaitingかprocessingの場合は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とサーバー側のキャンセル処理を別途実装します。

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

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

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


関連記事

  • React TanStack Queryで更新後のキャッシュを再取得する

    TanStack Queryでデータを更新しても、取得済みの一覧や詳細は自動では書き換わりません。更新後に古いデータが表示されないよう、関連するQueryを無効化して再取得します。今回は商品一覧と商品...


  • React TanStack Queryで初期データを複数のキャッシュへ分配する

    管理画面の初期表示で、店舗、商品カテゴリ、配送方法など複数のデータが必要になる場合があります。それぞれのAPIを同時に呼び出す代わりに、初期表示用APIでまとめて取得するとリクエスト数を減らせます。今...


  • React 入力値の重複チェックをデバウンスして実行する

    ユーザー名の登録フォームでは、入力した値がすでに使用されているかAPIで確認することがあります。入力のたびにAPIを呼び出すとリクエストが増えるため、入力が止まってから重複チェックを実行します。今回は...


  • TypeScript i18nextの動的な翻訳キーを抽出対象に含める

    i18nextでステータスに応じた文言を表示する場合、翻訳キーを動的に切り替えることがあります。ただし、実行時に正しく表示できることと、翻訳キーの抽出ツールが使用箇所を検出できることは別です。今回は、...


  • React useStateで入力フォームを作成する

    Reactでテキストボックスに入力した値を使用するには、useStateで値を保持します。今回は名前とメールアドレスを入力し、送信ボタンを押すと入力内容を表示するフォームを作成します。この例では入力値...


  • React useStateで一覧の追加と削除を行う

    Reactで一覧を表示するときは、配列をuseStateへ保存できます。今回は簡単な買い物リストを作り、項目の追加と削除を行います。配列のpushやspliceで既存のstateを直接変更するのではな...


  • React useRefで入力欄にフォーカスを当てる

    入力フォームを開いた直後や、入力エラーが出たときに、特定の入力欄へフォーカスしたいことがあります。DOMの要素自体を参照するにはuseRefを使います。currentは初回描画中などにnullになり得...


  • React useMemoで重い計算結果を再利用する

    一覧の絞り込みや並べ替えを描画のたびに行うと、データ量や計算内容によっては画面操作が遅くなります。useMemoは、依存する値が変わらない間、以前の計算結果を再利用します。productsまたはque...


  • React useEffectで無限ループが発生するときに確認すること

    ReactのuseEffectを利用したときに無限ループが発生してしまうことがあります。特に注意したいのが、ESLintのreact-hooks/exhaustive-depsで表示された警告をUpd...


  • React useEffectの後片付けでイベント登録を解除する

    画面の幅が変わったときに表示を更新したい場合、ブラウザのresizeイベントを登録できます。登録したままにすると、コンポーネントが不要になったあとも処理が残るため、useEffectの後片付けを用意し...