😊

React + SWRで手動ポーリングを簡潔にリファクタリングした話

に公開

背景

Webアプリケーションでは、サーバー側で実行される時間のかかる処理(ファイル変換、データ処理、レポート生成など)の進捗をリアルタイムで取得したい場面がよくあります。

従来、私たちのプロジェクトではsetIntervaluseEffectを組み合わせた手動ポーリングで非同期ジョブの状態を監視していましたが、コードが複雑化し、メモリリークのリスクもありました。

そこで、既にプロジェクトで使用していたSWRのrefreshIntervalオプションを活用してリファクタリングを行い、大幅にコードを簡潔化できたので紹介します。

Before: 手動ポーリングの実装

// Before: 手動でsetInterval/clearIntervalを管理
import { useEffect, useRef, useState } from "react";

const AsyncJobMonitor = () => {
  const [jobId, setJobId] = useState<string | null>(null);
  const [jobStatus, setJobStatus] = useState<string>("");
  const [jobResult, setJobResult] = useState<string>("");
  const [loading, setLoading] = useState(false);
  const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);

  // 複雑なポーリングロジック
  useEffect(() => {
    if (!jobId) return;

    const pollJobStatus = async () => {
      try {
        const response = await fetchJobStatus(jobId);
        
        if (response.result !== undefined) {
          setJobResult(response.result);
        }

        if (response.status === "SUCCESS") {
          setJobStatus("completed");
          setLoading(false);
          // ポーリング停止
          if (pollingIntervalRef.current) {
            clearInterval(pollingIntervalRef.current);
            pollingIntervalRef.current = null;
          }
        } else if (response.status === "FAILED") {
          setJobStatus("failed");
          setLoading(false);
          // ポーリング停止
          if (pollingIntervalRef.current) {
            clearInterval(pollingIntervalRef.current);
            pollingIntervalRef.current = null;
          }
          alert("処理が失敗しました");
        }
      } catch (error) {
        console.error("ジョブ状態取得エラー:", error);
        setLoading(false);
        if (pollingIntervalRef.current) {
          clearInterval(pollingIntervalRef.current);
          pollingIntervalRef.current = null;
        }
        alert("エラーが発生しました");
      }
    };

    // 既存のポーリングをクリア
    if (pollingIntervalRef.current) {
      clearInterval(pollingIntervalRef.current);
    }

    // 1秒間隔でポーリング開始
    pollingIntervalRef.current = setInterval(pollJobStatus, 1000);
    
    // 初回実行
    pollJobStatus();

  }, [jobId]);

  // クリーンアップ
  useEffect(() => {
    return () => {
      if (pollingIntervalRef.current) {
        clearInterval(pollingIntervalRef.current);
      }
    };
  }, []);

  const startJob = async () => {
    setLoading(true);
    const response = await executeAsyncJob();
    setJobId(response.jobId);
  };

  return (
    <div>
      <button onClick={startJob} disabled={loading}>
        {loading ? "処理中..." : "ジョブ開始"}
      </button>
      {jobStatus === "completed" && <div>処理完了: {jobResult}</div>}
      {jobStatus === "failed" && <div>処理失敗: {jobResult}</div>}
    </div>
  );
};

After: SWRを使った簡潔な実装

// After: SWRのrefreshIntervalを活用
import useSWR from "swr";
import { useState } from "react";

const AsyncJobMonitor = () => {
  const [jobId, setJobId] = useState<string | null>(null);
  const [jobStatus, setJobStatus] = useState<string>("");
  const [jobResult, setJobResult] = useState<string>("");
  const [loading, setLoading] = useState(false);
  const [shouldPoll, setShouldPoll] = useState(true);

  // SWRによる自動ポーリング
  useSWR(
    jobId ? `job-${jobId}` : null,
    async () => {
      if (!jobId) return null;
      const response = await fetchJobStatus(jobId);
      return response;
    },
    {
      refreshInterval: shouldPoll ? 1000 : 0, // 1秒間隔でポーリング
      revalidateOnFocus: false,
      revalidateOnReconnect: true,
      onSuccess: (data) => {
        if (!data) return;
        
        // 結果を設定
        if (data.result !== undefined) {
          setJobResult(data.result);
        }
        
        if (data.status === "SUCCESS") {
          setJobStatus("completed");
          setLoading(false);
          setShouldPoll(false); // ポーリング停止
        } else if (data.status === "FAILED") {
          setJobStatus("failed");
          setLoading(false);
          setShouldPoll(false); // ポーリング停止
          alert("処理が失敗しました");
        }
      },
      onError: (error) => {
        console.error("ジョブ状態取得エラー:", error);
        setLoading(false);
        setShouldPoll(false);
        alert("エラーが発生しました");
      }
    }
  );

  const startJob = async () => {
    setLoading(true);
    setShouldPoll(true);
    const response = await executeAsyncJob();
    setJobId(response.jobId);
  };

  return (
    <div>
      <button onClick={startJob} disabled={loading}>
        {loading ? "処理中..." : "ジョブ開始"}
      </button>
      {jobStatus === "completed" && <div>処理完了: {jobResult}</div>}
      {jobStatus === "failed" && <div>処理失敗: {jobResult}</div>}
    </div>
  );
};

改善のポイント

1. コード量の大幅削減

  • Before: 約50行のポーリングロジック
  • After: 約30行で同等の機能を実現

2. メモリリークのリスク軽減

  • 手動でのsetInterval/clearInterval管理が不要
  • SWRが自動的にクリーンアップを実行
  • コンポーネントのアンマウント時も安全

3. 宣言的なエラーハンドリング

// Before: try-catch内で複雑な状態管理
try {
  // ポーリング処理
} catch (error) {
  // エラー時のクリーンアップを手動で実行
}

// After: onErrorコールバックで宣言的に処理
{
  onError: (error) => {
    // SWRが自動的にポーリングを停止
    handleError(error);
  }
}

4. 条件付きポーリングの簡潔な実装

// refreshIntervalで動的にポーリング間隔を制御
refreshInterval: shouldPoll ? 1000 : 0

5. 重複リクエストの防止

{
  dedupingInterval: 500, // 500ms以内の重複リクエストを自動で除外
  revalidateOnFocus: false, // フォーカス時の自動再取得を無効化
}

SWR活用のメリット

1. 自動リソース管理

  • ポーリングの開始/停止をSWRが自動制御
  • メモリリークやゾンビタイマーのリスクを軽減

2. 豊富なオプション

  • refreshInterval: 動的なポーリング間隔制御
  • revalidateOnFocus/revalidateOnReconnect: 再検証タイミングの細かい制御
  • dedupingInterval: 重複リクエストの自動排除

3. 宣言的な記述

  • onSuccess/onErrorコールバックで状態変更を宣言的に記述
  • 副作用の管理が簡潔

4. キャッシュ機能

  • 同一キーでの重複リクエストを自動で最適化
  • ネットワーク効率の向上

まとめ

手動ポーリング処理をSWRにリファクタリングすることで:

  • 可読性向上: 複雑な状態管理ロジックが大幅に簡潔化
  • 保守性向上: メモリリークリスクの軽減、自動リソース管理
  • 開発効率向上: 宣言的な記述でバグの混入リスクを軽減

既にSWRを導入しているプロジェクトであれば、追加の依存関係なしで恩恵を受けられます。非同期処理の監視が必要な場面では、SWRのrefreshIntervalオプションを積極的に活用することをおすすめします。


この事例が同様の課題を抱える開発者の参考になれば幸いです。SWRには他にも多くの便利な機能があるので、ぜひ公式ドキュメントもチェックしてみてください!

Discussion