T-CREATOR

Zustandでの非同期処理とfetch連携パターン(パターン 7: リクエストのキャンセルと競合状態の管理)

Zustandでの非同期処理とfetch連携パターン(パターン 7: リクエストのキャンセルと競合状態の管理)

非同期処理を扱う Web アプリケーションでは、リクエストのキャンセルや複数の非同期処理の競合状態を適切に管理することが重要です。この記事では、Zustand を使ったリクエストのキャンセルと競合状態の管理方法について詳しく解説します。

パターン 7: リクエストのキャンセルと競合状態の管理

ユーザーインタラクションや画面遷移によって、実行中の API リクエストをキャンセルしたり、競合する非同期処理を適切に管理する必要が生じることがあります。Zustand でこれらのシナリオを管理する方法を見ていきましょう。

AbortController を使ったリクエストキャンセル

Fetch API の AbortController を使用して、リクエストをキャンセルする基本的な実装を見てみましょう:

typescript// src/stores/cancelableRequestStore.ts
import { create } from 'zustand';

interface CancelableRequestStore<T> {
  data: T | null;
  isLoading: boolean;
  error: string | null;

  // コントローラーの参照
  controller: AbortController | null;

  // アクション
  fetchData: (url: string) => Promise<void>;
  cancelRequest: () => void;
}

export const createCancelableStore = <T>() =>
  create<CancelableRequestStore<T>>((set, get) => ({
    data: null,
    isLoading: false,
    error: null,
    controller: null,

    fetchData: async (url) => {
      // 既存のリクエストがあればキャンセル
      if (get().controller) {
        get().cancelRequest();
      }

      // 新しいコントローラーを作成
      const controller = new AbortController();
      set({ controller, isLoading: true, error: null });

      try {
        const response = await fetch(url, {
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error(`Error: ${response.status}`);
        }

        const data = await response.json();
        set({ data, isLoading: false, controller: null });
      } catch (error) {
        // AbortError は通常のエラーとして扱わない
        if (
          error instanceof Error &&
          error.name === 'AbortError'
        ) {
          set({ isLoading: false, controller: null });
          return;
        }

        set({
          error:
            error instanceof Error
              ? error.message
              : '取得エラー',
          isLoading: false,
          controller: null,
        });
      }
    },

    cancelRequest: () => {
      const { controller } = get();
      if (controller) {
        controller.abort();
        set({ controller: null, isLoading: false });
      }
    },
  }));

// 使用例
export const useSearchStore =
  createCancelableStore<any[]>();

ユースケース: 検索フォーム

検索入力に応じて自動的にリクエストを行い、新しい入力があれば前のリクエストをキャンセルする検索フォームの例:

tsx// src/components/SearchForm.tsx
import React, { useState, useEffect } from 'react';
import { useSearchStore } from '../stores/cancelableRequestStore';

export const SearchForm: React.FC = () => {
  const [query, setQuery] = useState('');
  const {
    data: results,
    isLoading,
    error,
    fetchData,
    cancelRequest,
  } = useSearchStore();

  // 検索クエリが変更されたときの処理
  useEffect(() => {
    // 空のクエリの場合は何もしない
    if (!query.trim()) return;

    // デバウンス処理
    const debounceTimeout = setTimeout(() => {
      fetchData(
        `/api/search?q=${encodeURIComponent(query)}`
      );
    }, 300);

    // クリーンアップ関数でタイムアウトとリクエストをキャンセル
    return () => {
      clearTimeout(debounceTimeout);
      cancelRequest();
    };
  }, [query, fetchData, cancelRequest]);

  return (
    <div className='search-container'>
      <div className='search-form'>
        <input
          type='text'
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder='検索キーワードを入力...'
          className='search-input'
        />
        {isLoading && (
          <span className='loading-indicator'>
            検索中...
          </span>
        )}
      </div>

      {error && (
        <div className='error-message'>{error}</div>
      )}

      <div className='search-results'>
        {results && results.length > 0
          ? results.map((item) => (
              <div key={item.id} className='result-item'>
                <h3>{item.title}</h3>
                <p>{item.description}</p>
              </div>
            ))
          : !isLoading &&
            query && (
              <div className='no-results'>
                結果が見つかりませんでした
              </div>
            )}
      </div>
    </div>
  );
};

レースコンディションの管理

同じリソースに対する複数のリクエストが並行して実行されると、レースコンディション(競合状態)が発生する可能性があります。これを管理するためのパターンを見てみましょう:

typescript// src/stores/raceConditionStore.ts
import { create } from 'zustand';

interface RequestMetadata {
  id: string;
  timestamp: number;
}

interface RaceConditionStore<T> {
  data: T | null;
  isLoading: boolean;
  error: string | null;

  // 最新のリクエストを追跡
  latestRequest: RequestMetadata | null;

  // アクション
  fetchData: (id: string, url: string) => Promise<void>;
}

export const createRaceConditionStore = <T>() =>
  create<RaceConditionStore<T>>((set, get) => ({
    data: null,
    isLoading: false,
    error: null,
    latestRequest: null,

    fetchData: async (id, url) => {
      // 一意のリクエスト識別子とタイムスタンプを作成
      const requestMetadata: RequestMetadata = {
        id,
        timestamp: Date.now(),
      };

      // このリクエストを最新として記録
      set({
        latestRequest: requestMetadata,
        isLoading: true,
        error: null,
      });

      try {
        const response = await fetch(url);

        if (!response.ok) {
          throw new Error(`Error: ${response.status}`);
        }

        const data = await response.json();

        // リクエスト完了時に、このリクエストが依然として最新かチェック
        if (
          get().latestRequest?.id === requestMetadata.id
        ) {
          set({
            data,
            isLoading: false,
          });
        } else {
          // このリクエストは古いので結果を無視
          console.log('Ignoring stale response:', id);
        }
      } catch (error) {
        // このリクエストが依然として最新の場合のみエラーを設定
        if (
          get().latestRequest?.id === requestMetadata.id
        ) {
          set({
            error:
              error instanceof Error
                ? error.message
                : '取得エラー',
            isLoading: false,
          });
        }
      }
    },
  }));

// 使用例
export const useUserDetailsStore =
  createRaceConditionStore<any>();

ユースケース: ユーザープロファイルの切り替え

ユーザーが複数のプロファイルをすばやく切り替える場合の競合状態管理の例:

tsx// src/components/UserProfileSwitcher.tsx
import React, { useEffect } from 'react';
import { useUserDetailsStore } from '../stores/raceConditionStore';

interface User {
  id: string;
  name: string;
}

interface UserProfileSwitcherProps {
  users: User[];
  selectedUserId: string;
  onSelectUser: (userId: string) => void;
}

export const UserProfileSwitcher: React.FC<
  UserProfileSwitcherProps
> = ({ users, selectedUserId, onSelectUser }) => {
  const {
    data: userDetails,
    isLoading,
    error,
    fetchData,
  } = useUserDetailsStore();

  // ユーザーが選択されたときの処理
  useEffect(() => {
    if (selectedUserId) {
      // ユニークなリクエストIDとしてユーザーIDを使用
      fetchData(
        selectedUserId,
        `/api/users/${selectedUserId}/details`
      );
    }
  }, [selectedUserId, fetchData]);

  return (
    <div className='user-profile-container'>
      <div className='user-selector'>
        <h3>ユーザー選択</h3>
        <div className='user-list'>
          {users.map((user) => (
            <button
              key={user.id}
              className={
                selectedUserId === user.id ? 'active' : ''
              }
              onClick={() => onSelectUser(user.id)}
            >
              {user.name}
            </button>
          ))}
        </div>
      </div>

      <div className='user-details'>
        <h3>ユーザー詳細</h3>

        {isLoading && (
          <div className='loading'>読み込み中...</div>
        )}

        {error && (
          <div className='error-message'>{error}</div>
        )}

        {userDetails && !isLoading && (
          <div className='details-container'>
            <div className='detail-row'>
              <span className='label'>名前:</span>
              <span className='value'>
                {userDetails.name}
              </span>
            </div>
            <div className='detail-row'>
              <span className='label'>メール:</span>
              <span className='value'>
                {userDetails.email}
              </span>
            </div>
            <div className='detail-row'>
              <span className='label'>電話:</span>
              <span className='value'>
                {userDetails.phone}
              </span>
            </div>
            <div className='detail-row'>
              <span className='label'>住所:</span>
              <span className='value'>
                {userDetails.address}
              </span>
            </div>
            {/* その他の詳細情報 */}
          </div>
        )}
      </div>
    </div>
  );
};

タイムアウト処理の実装

長時間実行されるリクエストを適切に処理するためのタイムアウト機能を実装する例:

typescript// src/stores/timeoutStore.ts
import { create } from 'zustand';

interface TimeoutRequestStore<T> {
  data: T | null;
  isLoading: boolean;
  error: string | null;

  // コントローラーの参照
  controller: AbortController | null;

  // アクション
  fetchWithTimeout: (
    url: string,
    timeoutMs?: number
  ) => Promise<void>;
  cancelRequest: () => void;
}

export const createTimeoutStore = <T>() =>
  create<TimeoutRequestStore<T>>((set, get) => ({
    data: null,
    isLoading: false,
    error: null,
    controller: null,

    fetchWithTimeout: async (url, timeoutMs = 10000) => {
      // 既存のリクエストがあればキャンセル
      if (get().controller) {
        get().cancelRequest();
      }

      // 新しいコントローラーを作成
      const controller = new AbortController();
      set({ controller, isLoading: true, error: null });

      // タイムアウトタイマーを設定
      const timeoutId = setTimeout(() => {
        if (get().controller === controller) {
          controller.abort();
          set({
            error: `Request timed out after ${timeoutMs}ms`,
            isLoading: false,
            controller: null,
          });
        }
      }, timeoutMs);

      try {
        const response = await fetch(url, {
          signal: controller.signal,
        });

        // タイムアウトタイマーをクリア
        clearTimeout(timeoutId);

        if (!response.ok) {
          throw new Error(`Error: ${response.status}`);
        }

        const data = await response.json();
        set({ data, isLoading: false, controller: null });
      } catch (error) {
        // タイムアウトタイマーをクリア
        clearTimeout(timeoutId);

        // AbortError はタイムアウトによるものかもしれないので確認
        if (
          error instanceof Error &&
          error.name === 'AbortError'
        ) {
          // すでにタイムアウトエラーが設定されている場合は何もしない
          if (!get().error) {
            set({
              error: 'Request was aborted',
              isLoading: false,
              controller: null,
            });
          }
          return;
        }

        set({
          error:
            error instanceof Error
              ? error.message
              : '取得エラー',
          isLoading: false,
          controller: null,
        });
      }
    },

    cancelRequest: () => {
      const { controller } = get();
      if (controller) {
        controller.abort();
        set({ controller: null, isLoading: false });
      }
    },
  }));

// 使用例
export const useLongRunningTaskStore =
  createTimeoutStore<any>();

複数のリクエストの状態管理

複数の並行リクエストの状態を個別に管理する例:

typescript// src/stores/multiRequestStore.ts
import { create } from 'zustand';

interface RequestState<T> {
  data: T | null;
  isLoading: boolean;
  error: string | null;
  controller: AbortController | null;
}

interface MultiRequestStore<T> {
  // リクエストIDごとの状態を保持
  requests: Record<string, RequestState<T>>;

  // アクション
  startRequest: (
    requestId: string,
    url: string
  ) => Promise<void>;
  cancelRequest: (requestId: string) => void;
  cancelAllRequests: () => void;
  clearRequest: (requestId: string) => void;
}

export const createMultiRequestStore = <T>() =>
  create<MultiRequestStore<T>>((set, get) => ({
    requests: {},

    startRequest: async (requestId, url) => {
      // 既存のリクエストがあれば状態を取得
      const existingRequest = get().requests[requestId];

      // 既存のリクエストがあればキャンセル
      if (existingRequest?.controller) {
        existingRequest.controller.abort();
      }

      // 新しいコントローラーを作成
      const controller = new AbortController();

      // このリクエストの初期状態を設定
      set((state) => ({
        requests: {
          ...state.requests,
          [requestId]: {
            data: existingRequest?.data || null,
            isLoading: true,
            error: null,
            controller,
          },
        },
      }));

      try {
        const response = await fetch(url, {
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error(`Error: ${response.status}`);
        }

        const data = await response.json();

        set((state) => ({
          requests: {
            ...state.requests,
            [requestId]: {
              data,
              isLoading: false,
              error: null,
              controller: null,
            },
          },
        }));
      } catch (error) {
        // AbortError は通常のエラーとして扱わない
        if (
          error instanceof Error &&
          error.name === 'AbortError'
        ) {
          set((state) => ({
            requests: {
              ...state.requests,
              [requestId]: {
                ...state.requests[requestId],
                isLoading: false,
                controller: null,
              },
            },
          }));
          return;
        }

        set((state) => ({
          requests: {
            ...state.requests,
            [requestId]: {
              ...state.requests[requestId],
              error:
                error instanceof Error
                  ? error.message
                  : '取得エラー',
              isLoading: false,
              controller: null,
            },
          },
        }));
      }
    },

    cancelRequest: (requestId) => {
      const request = get().requests[requestId];
      if (request?.controller) {
        request.controller.abort();

        set((state) => ({
          requests: {
            ...state.requests,
            [requestId]: {
              ...state.requests[requestId],
              isLoading: false,
              controller: null,
            },
          },
        }));
      }
    },

    cancelAllRequests: () => {
      const { requests } = get();

      // すべてのアクティブなリクエストをキャンセル
      Object.entries(requests).forEach(([id, request]) => {
        if (request.controller) {
          request.controller.abort();
        }
      });

      // すべてのリクエストの読み込み状態をリセット
      set((state) => {
        const updatedRequests = { ...state.requests };

        Object.keys(updatedRequests).forEach((id) => {
          updatedRequests[id] = {
            ...updatedRequests[id],
            isLoading: false,
            controller: null,
          };
        });

        return { requests: updatedRequests };
      });
    },

    clearRequest: (requestId) => {
      set((state) => {
        const updatedRequests = { ...state.requests };
        delete updatedRequests[requestId];
        return { requests: updatedRequests };
      });
    },
  }));

// 使用例
export const useDashboardStore =
  createMultiRequestStore<any>();

ユースケース: ダッシュボードウィジェット

複数のウィジェットを持つダッシュボードで、各ウィジェットが独立して非同期データを取得する例:

tsx// src/components/Dashboard.tsx
import React, { useEffect } from 'react';
import { useDashboardStore } from '../stores/multiRequestStore';

interface Widget {
  id: string;
  title: string;
  endpoint: string;
}

interface DashboardProps {
  widgets: Widget[];
}

export const Dashboard: React.FC<DashboardProps> = ({
  widgets,
}) => {
  const {
    requests,
    startRequest,
    cancelRequest,
    clearRequest,
  } = useDashboardStore();

  // 各ウィジェットのデータを取得
  useEffect(() => {
    widgets.forEach((widget) => {
      startRequest(widget.id, widget.endpoint);
    });

    // クリーンアップ関数ですべてのリクエストをキャンセル
    return () => {
      widgets.forEach((widget) => {
        cancelRequest(widget.id);
      });
    };
  }, [widgets, startRequest, cancelRequest]);

  // ウィジェットのリフレッシュ処理
  const refreshWidget = (
    widgetId: string,
    endpoint: string
  ) => {
    startRequest(widgetId, endpoint);
  };

  // ウィジェットの削除処理
  const removeWidget = (widgetId: string) => {
    cancelRequest(widgetId);
    clearRequest(widgetId);
    // ここで親コンポーネントに削除を通知するコールバックを呼び出すなど
  };

  return (
    <div className='dashboard'>
      <h1>ダッシュボード</h1>
      <div className='widgets-grid'>
        {widgets.map((widget) => {
          const request = requests[widget.id] || {
            data: null,
            isLoading: true,
            error: null,
          };

          return (
            <div key={widget.id} className='widget'>
              <div className='widget-header'>
                <h3>{widget.title}</h3>
                <div className='widget-actions'>
                  <button
                    onClick={() =>
                      refreshWidget(
                        widget.id,
                        widget.endpoint
                      )
                    }
                    disabled={request.isLoading}
                  >
                    更新
                  </button>
                  <button
                    onClick={() => removeWidget(widget.id)}
                    className='remove-btn'
                  >
                    削除
                  </button>
                </div>
              </div>

              <div className='widget-content'>
                {request.isLoading && (
                  <div className='widget-loading'>
                    読み込み中...
                  </div>
                )}

                {request.error && (
                  <div className='widget-error'>
                    {request.error}
                  </div>
                )}

                {!request.isLoading &&
                  !request.error &&
                  request.data && (
                    <div className='widget-data'>
                      {/* ウィジェットのデータ表示 */}
                      {typeof request.data === 'object' ? (
                        <pre>
                          {JSON.stringify(
                            request.data,
                            null,
                            2
                          )}
                        </pre>
                      ) : (
                        <div>{String(request.data)}</div>
                      )}
                    </div>
                  )}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
};

Promise のキャンセルパターン

すべての非同期処理が AbortController をサポートしているわけではないため、独自のキャンセル可能な Promise を実装する例:

typescript// src/utils/cancelablePromise.ts
export interface CancelablePromise<T> extends Promise<T> {
  cancel: () => void;
}

export function makeCancelable<T>(
  promise: Promise<T>
): CancelablePromise<T> {
  let isCanceled = false;
  let cancelReject: ((reason?: any) => void) | null = null;

  // キャンセル可能なPromiseを作成
  const wrappedPromise = new Promise<T>(
    (resolve, reject) => {
      cancelReject = reject;

      promise
        .then((value) => {
          if (!isCanceled) {
            resolve(value);
          }
        })
        .catch((error) => {
          if (!isCanceled) {
            reject(error);
          }
        });
    }
  ) as CancelablePromise<T>;

  // キャンセルメソッドを追加
  wrappedPromise.cancel = () => {
    isCanceled = true;
    if (cancelReject) {
      cancelReject({ isCanceled: true });
    }
  };

  return wrappedPromise;
}

// 使用例
// const cancelablePromise = makeCancelable(fetch('/api/data').then(res => res.json()));
// cancelablePromise.cancel(); // リクエストをキャンセル

これを Zustand ストアで使用する例:

typescript// src/stores/cancelablePromiseStore.ts
import { create } from 'zustand';
import {
  makeCancelable,
  CancelablePromise,
} from '../utils/cancelablePromise';

interface CancelablePromiseStore<T> {
  data: T | null;
  isLoading: boolean;
  error: string | null;

  // 現在のPromiseを保持
  currentPromise: CancelablePromise<T> | null;

  // アクション
  executeTask: <P>(
    task: (params: P) => Promise<T>,
    params: P
  ) => Promise<void>;
  cancelTask: () => void;
}

export const createCancelablePromiseStore = <T>() =>
  create<CancelablePromiseStore<T>>((set, get) => ({
    data: null,
    isLoading: false,
    error: null,
    currentPromise: null,

    executeTask: async (task, params) => {
      // 既存のタスクがあればキャンセル
      if (get().currentPromise) {
        get().cancelTask();
      }

      set({ isLoading: true, error: null });

      try {
        // タスクをキャンセル可能なPromiseでラップ
        const promise = makeCancelable(task(params));
        set({ currentPromise: promise });

        const data = await promise;
        set({
          data,
          isLoading: false,
          currentPromise: null,
        });
      } catch (error) {
        // キャンセルされた場合は通常のエラーとして扱わない
        if (
          error &&
          typeof error === 'object' &&
          'isCanceled' in error
        ) {
          set({ isLoading: false, currentPromise: null });
          return;
        }

        set({
          error:
            error instanceof Error
              ? error.message
              : '実行エラー',
          isLoading: false,
          currentPromise: null,
        });
      }
    },

    cancelTask: () => {
      const { currentPromise } = get();
      if (currentPromise) {
        currentPromise.cancel();
        set({ currentPromise: null, isLoading: false });
      }
    },
  }));

ポイント

リクエストキャンセルと競合状態の管理における重要なポイント:

  1. リクエストの識別: 各リクエストを一意に識別し、追跡する仕組みを実装します。

  2. クリーンアップ: コンポーネントのアンマウント時にリクエストをキャンセルし、メモリリークを防止します。

  3. レースコンディション: 複数のリクエストが並行して実行される場合、最新のリクエストの結果のみを適用します。

  4. ユーザーフィードバック: リクエストの状態(ローディング、エラー)を適切にユーザーに伝えます。

  5. エラー処理: キャンセルされたリクエストと実際のエラーを区別します。

実装パターンの比較

パターン用途利点欠点
AbortController標準的な Fetch リクエストブラウザネイティブ API、シンプル古いブラウザでは未サポート
レースコンディション管理複数の並行リクエスト最新のリクエスト結果のみを使用リクエスト自体はキャンセルされない
タイムアウト処理長時間実行リクエストユーザー体験の向上タイムアウト時間の適切な設定が必要
複数リクエスト管理ダッシュボード等の独立コンポーネント個別の状態管理ストア設計の複雑化
カスタムキャンセルサードパーティライブラリとの統合柔軟性が高い実装の複雑さ

まとめ

この記事では、Zustand を使ったリクエストのキャンセルと競合状態の管理について解説しました。非同期処理を多用するモダンな Web アプリケーションでは、これらのパターンを適切に実装することが重要です。

Zustand の状態管理の柔軟性を活かすことで、以下のような課題に対処できます:

  1. 不要なリクエストのキャンセル: ユーザーの操作や画面遷移に応じて進行中のリクエストをキャンセルし、リソースを節約できます。

  2. 競合状態の適切な処理: 複数の非同期処理が並行して実行される場合でも、最新の結果のみを使用することで、UI の一貫性を保てます。

  3. ユーザー体験の向上: 長時間実行されるリクエストにタイムアウトを設けることで、ユーザーをいつまでも待たせないようにできます。

  4. 複数のリクエスト状態の管理: 複数の独立したコンポーネントがそれぞれ非同期データを取得する場合でも、状態を適切に分離して管理できます。

これらのパターンを理解し、適切に実装することで、より堅牢で使いやすいアプリケーションを構築できるでしょう。

関連リンク