T-CREATOR

Zustandでの非同期処理とfetch連携パターン(パターン 5: リクエストの依存関係と連鎖)

Zustandでの非同期処理とfetch連携パターン(パターン 5: リクエストの依存関係と連鎖)

複雑な Web アプリケーションでは、API リクエスト間に依存関係が生じることがよくあります。この記事では、Zustand を使って複数の依存関係を持つ API リクエストを効率的に管理する方法について詳しく解説します。

パターン 5: リクエストの依存関係と連鎖

多くのアプリケーションでは、複数の API リクエストが依存関係を持ったり、連鎖的に実行される必要があります。これらのシナリオを Zustand で効果的に管理する方法を見ていきましょう。

ユースケース: ユーザー認証と権限に基づくデータ取得

ユーザーログイン後に権限情報を取得し、それに基づいて別のデータを取得する例:

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

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

interface Permission {
  resourceType: string;
  actions: string[];
}

interface AuthDataStore {
  // 状態
  user: User | null;
  permissions: Permission[];
  resources: any[];

  // ローディング状態
  isAuthenticating: boolean;
  isLoadingPermissions: boolean;
  isLoadingResources: boolean;

  // エラー状態
  authError: string | null;
  permissionsError: string | null;
  resourcesError: string | null;

  // アクション
  login: (
    email: string,
    password: string
  ) => Promise<boolean>;
  loadUserData: () => Promise<void>;
}

export const useAuthDataStore = create<AuthDataStore>(
  (set, get) => ({
    // 初期状態
    user: null,
    permissions: [],
    resources: [],

    isAuthenticating: false,
    isLoadingPermissions: false,
    isLoadingResources: false,

    authError: null,
    permissionsError: null,
    resourcesError: null,

    // ログインアクション
    login: async (email, password) => {
      set({ isAuthenticating: true, authError: null });

      try {
        const response = await fetch('/api/login', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ email, password }),
        });

        if (!response.ok)
          throw new Error('Invalid credentials');

        const userData = await response.json();
        set({ user: userData, isAuthenticating: false });

        // ログイン成功後、自動的に追加データの取得を開始
        get().loadUserData();

        return true;
      } catch (error) {
        set({
          authError:
            error instanceof Error
              ? error.message
              : '認証エラー',
          isAuthenticating: false,
        });
        return false;
      }
    },

    // ユーザーデータ読み込みアクション(依存関係を持つリクエストを連鎖的に実行)
    loadUserData: async () => {
      const { user } = get();

      if (!user) {
        set({
          permissionsError: 'ユーザーが認証されていません',
        });
        return;
      }

      // 1. まず権限情報を取得
      set({
        isLoadingPermissions: true,
        permissionsError: null,
      });

      try {
        const permissionsResponse = await fetch(
          `/api/users/${user.id}/permissions`
        );
        if (!permissionsResponse.ok)
          throw new Error('Failed to fetch permissions');

        const permissions =
          await permissionsResponse.json();
        set({ permissions, isLoadingPermissions: false });

        // 2. 権限に基づいてリソースを取得
        set({
          isLoadingResources: true,
          resourcesError: null,
        });

        // 対象リソースを権限から判断
        const accessibleResourceTypes = permissions
          .filter((perm) => perm.actions.includes('read'))
          .map((perm) => perm.resourceType);

        if (accessibleResourceTypes.length === 0) {
          set({ resources: [], isLoadingResources: false });
          return;
        }

        // リソースの取得(並列リクエスト)
        try {
          const resourcePromises =
            accessibleResourceTypes.map((resourceType) =>
              fetch(`/api/resources/${resourceType}`).then(
                (res) => (res.ok ? res.json() : [])
              )
            );

          const resourceResults = await Promise.all(
            resourcePromises
          );

          // すべてのリソースを一つの配列にフラット化
          const allResources = resourceResults.flat();

          set({
            resources: allResources,
            isLoadingResources: false,
          });
        } catch (resourceError) {
          set({
            resourcesError:
              resourceError instanceof Error
                ? resourceError.message
                : 'リソース取得エラー',
            isLoadingResources: false,
          });
        }
      } catch (permError) {
        set({
          permissionsError:
            permError instanceof Error
              ? permError.message
              : '権限取得エラー',
          isLoadingPermissions: false,
        });
        // 権限取得に失敗した場合、リソース取得はスキップ
      }
    },
  })
);

依存関係のある非同期タスクの管理

複雑な依存関係を持つ非同期タスクを管理するためのヘルパー関数:

typescript// src/utils/asyncTaskManager.ts

// 依存関係を持つ非同期タスクの定義
type AsyncTask<T> = {
  id: string;
  dependsOn: string[];
  task: () => Promise<T>;
};

// タスク実行関数
export async function executeTasks<T>(
  tasks: AsyncTask<T>[]
): Promise<Record<string, T>> {
  const results: Record<string, T> = {};
  const completed = new Set<string>();
  const pending = new Set(tasks.map((t) => t.id));

  // すべてのタスクが完了するまで繰り返す
  while (pending.size > 0) {
    let progress = false;

    // 実行可能なタスクを探す
    for (const task of tasks) {
      if (!pending.has(task.id)) continue;

      // すべての依存タスクが完了しているかチェック
      const canExecute = task.dependsOn.every((depId) =>
        completed.has(depId)
      );

      if (canExecute) {
        // 依存関係が満たされているタスクを実行
        try {
          results[task.id] = await task.task();
          completed.add(task.id);
          pending.delete(task.id);
          progress = true;
        } catch (error) {
          // エラー処理
          throw new Error(
            `タスク "${task.id}" でエラーが発生しました: ${error}`
          );
        }
      }
    }

    // どのタスクも進行しなかった場合、循環依存などの問題がある
    if (!progress && pending.size > 0) {
      throw new Error(
        '依存関係を解決できませんでした。循環依存の可能性があります。'
      );
    }
  }

  return results;
}

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

typescript// src/stores/dataInitStore.ts
import { create } from 'zustand';
import { executeTasks } from '../utils/asyncTaskManager';

interface DataInitStore {
  // 各データセットの状態
  config: any | null;
  user: any | null;
  products: any[] | null;

  // ローディング状態
  isLoading: boolean;
  error: string | null;

  // 初期化アクション
  initializeApp: () => Promise<void>;
}

export const useDataInitStore = create<DataInitStore>(
  (set, get) => ({
    config: null,
    user: null,
    products: null,

    isLoading: false,
    error: null,

    initializeApp: async () => {
      set({ isLoading: true, error: null });

      try {
        // 依存関係のあるタスクを定義
        const tasks = [
          {
            id: 'config',
            dependsOn: [], // 依存なし
            task: async () => {
              const res = await fetch('/api/config');
              return res.json();
            },
          },
          {
            id: 'user',
            dependsOn: ['config'], // configの後に実行
            task: async () => {
              const config = get().config;
              const res = await fetch(
                `/api/user?region=${config.region}`
              );
              return res.json();
            },
          },
          {
            id: 'products',
            dependsOn: ['config', 'user'], // configとuserの両方が必要
            task: async () => {
              const config = get().config;
              const user = get().user;
              const res = await fetch(
                `/api/products?region=${config.region}&category=${user.preferences.category}`
              );
              return res.json();
            },
          },
        ];

        // 依存関係を考慮してタスクを実行
        const results = await executeTasks(tasks);

        // 取得したデータで状態を更新
        set({
          config: results.config,
          user: results.user,
          products: results.products,
          isLoading: false,
        });
      } catch (error) {
        set({
          error:
            error instanceof Error
              ? error.message
              : '初期化エラー',
          isLoading: false,
        });
      }
    },
  })
);

依存関係グラフの視覚化と追跡

複雑なアプリケーションでは、依存関係を視覚化し、実行状況を追跡することが役立ちます:

typescript// 依存関係グラフの状態追跡
const useDependencyGraphStore =
  create<DependencyGraphStore>((set, get) => ({
    taskStatus: {}, // 各タスクのステータス
    executionOrder: [], // 実行順序を記録

    // タスク状態を更新
    updateTaskStatus: (taskId, status, error = null) => {
      set((state) => ({
        taskStatus: {
          ...state.taskStatus,
          [taskId]: {
            status,
            error,
            updatedAt: Date.now(),
          },
        },
      }));

      if (status === 'completed' || status === 'failed') {
        set((state) => ({
          executionOrder: [
            ...state.executionOrder,
            {
              taskId,
              status,
              timestamp: Date.now(),
            },
          ],
        }));
      }
    },

    // タスクの依存関係を考慮して実行する拡張版
    executeTasksWithTracking: async (tasks) => {
      // 初期状態設定
      const taskIds = tasks.map((t) => t.id);
      set({
        taskStatus: taskIds.reduce(
          (acc, id) => ({
            ...acc,
            [id]: {
              status: 'pending',
              error: null,
              updatedAt: Date.now(),
            },
          }),
          {}
        ),
        executionOrder: [],
      });

      // 以下、executeTasks関数と同様の実装だが、状態更新を追加
      const results = {};
      const completed = new Set();
      const pending = new Set(taskIds);

      while (pending.size > 0) {
        let progress = false;

        for (const task of tasks) {
          if (!pending.has(task.id)) continue;

          // 依存関係チェック
          const canExecute = task.dependsOn.every((depId) =>
            completed.has(depId)
          );

          if (canExecute) {
            // 実行中に状態更新
            get().updateTaskStatus(task.id, 'running');

            try {
              results[task.id] = await task.task();
              completed.add(task.id);
              pending.delete(task.id);
              progress = true;

              // 成功状態に更新
              get().updateTaskStatus(task.id, 'completed');
            } catch (error) {
              // エラー状態に更新
              get().updateTaskStatus(
                task.id,
                'failed',
                error.message
              );
              throw error;
            }
          }
        }

        if (!progress && pending.size > 0) {
          // 依存関係エラーの処理
          const pendingTasks = Array.from(pending);
          pendingTasks.forEach((taskId) => {
            get().updateTaskStatus(
              taskId,
              'blocked',
              '依存するタスクが完了していないか、循環依存があります'
            );
          });

          throw new Error('依存関係を解決できませんでした');
        }
      }

      return results;
    },

    // 診断情報の取得
    getDiagnostics: () => {
      const { taskStatus, executionOrder } = get();

      return {
        completedTasks: Object.entries(taskStatus)
          .filter(
            ([_, status]) => status.status === 'completed'
          )
          .map(([id]) => id),
        failedTasks: Object.entries(taskStatus)
          .filter(
            ([_, status]) => status.status === 'failed'
          )
          .map(([id, status]) => ({
            id,
            error: status.error,
          })),
        pendingTasks: Object.entries(taskStatus)
          .filter(([_, status]) =>
            ['pending', 'running'].includes(status.status)
          )
          .map(([id]) => id),
        executionTimeline: executionOrder,
        totalExecutionTime:
          executionOrder.length > 0
            ? executionOrder[executionOrder.length - 1]
                .timestamp - executionOrder[0].timestamp
            : 0,
      };
    },
  }));

シーケンシャルとパラレル実行の組み合わせ

依存関係がある場合は直列実行、独立している場合は並列実行を組み合わせる例:

typescript// 最適化された実行戦略
const executeOptimizedTasks = async (tasks) => {
  // 1. 依存関係グラフを構築
  const dependencyGraph = {};
  const reverseDependencyGraph = {};

  tasks.forEach((task) => {
    dependencyGraph[task.id] = task.dependsOn;

    // 逆依存関係グラフの構築(どのタスクがこのタスクに依存しているか)
    task.dependsOn.forEach((depId) => {
      if (!reverseDependencyGraph[depId]) {
        reverseDependencyGraph[depId] = [];
      }
      reverseDependencyGraph[depId].push(task.id);
    });
  });

  // 2. レベルごとにタスクをグループ化(同じレベルのタスクは並列実行可能)
  const levels = [];
  const taskLevels = {};

  // 依存関係がないタスク(レベル0)を特定
  const rootTasks = tasks
    .filter((task) => task.dependsOn.length === 0)
    .map((task) => task.id);

  levels.push(rootTasks);
  rootTasks.forEach((taskId) => {
    taskLevels[taskId] = 0;
  });

  // 残りのタスクのレベルを計算
  let currentLevel = 0;
  let allAssigned = false;

  while (!allAssigned) {
    const nextLevelTasks = [];

    levels[currentLevel].forEach((taskId) => {
      const dependents =
        reverseDependencyGraph[taskId] || [];

      dependents.forEach((depTaskId) => {
        // このタスクに依存するすべてのタスクが既にレベル割り当て済みかチェック
        const task = tasks.find((t) => t.id === depTaskId);
        const allDependenciesAssigned =
          task.dependsOn.every(
            (depId) => taskLevels[depId] !== undefined
          );

        if (
          allDependenciesAssigned &&
          taskLevels[depTaskId] === undefined
        ) {
          nextLevelTasks.push(depTaskId);
          taskLevels[depTaskId] = currentLevel + 1;
        }
      });
    });

    if (nextLevelTasks.length === 0) {
      allAssigned = true;
    } else {
      levels.push(nextLevelTasks);
      currentLevel++;
    }
  }

  // 3. レベルごとに並列実行
  const results = {};

  for (let level = 0; level < levels.length; level++) {
    const levelTasks = levels[level];
    const levelTaskObjects = tasks.filter((task) =>
      levelTasks.includes(task.id)
    );

    // このレベルのタスクを並列実行
    const levelResults = await Promise.all(
      levelTaskObjects.map(async (task) => {
        try {
          const result = await task.task();
          return { id: task.id, result, success: true };
        } catch (error) {
          return { id: task.id, error, success: false };
        }
      })
    );

    // 結果を集約
    levelResults.forEach(
      ({ id, result, success, error }) => {
        if (success) {
          results[id] = result;
        } else {
          // エラーハンドリング - ここでは例としてエラーを投げる
          throw new Error(`Task ${id} failed: ${error}`);
        }
      }
    );
  }

  return results;
};

キャッシュを活用した依存関係の最適化

依存関係のあるリクエストでも、結果をキャッシュすることで再実行を最適化できます:

typescript// キャッシュを活用した依存タスク実行
const useCachedDependencyStore = create((set, get) => ({
  cache: {}, // { taskId: { data, timestamp } }
  cacheConfig: {
    ttl: 5 * 60 * 1000, // 5分間キャッシュ有効
  },

  // キャッシュ付きタスク実行
  executeWithCache: async (tasks, options = {}) => {
    const { forceFresh = false } = options;
    const results = {};
    const now = Date.now();
    const { cache, cacheConfig } = get();

    // 既存のタスクをフィルタリング(キャッシュの活用)
    const tasksToExecute = forceFresh
      ? tasks
      : tasks.filter((task) => {
          const cachedData = cache[task.id];
          const isCacheValid =
            cachedData &&
            now - cachedData.timestamp < cacheConfig.ttl;

          if (isCacheValid) {
            // キャッシュから結果を取得
            results[task.id] = cachedData.data;
            return false;
          }
          return true;
        });

    if (tasksToExecute.length === 0) {
      return results; // すべてキャッシュから取得できた
    }

    // 依存関係を考慮して残りのタスクを実行
    const freshResults = await executeTasks(tasksToExecute);

    // 結果をキャッシュに保存
    Object.entries(freshResults).forEach(([id, data]) => {
      set((state) => ({
        cache: {
          ...state.cache,
          [id]: { data, timestamp: Date.now() },
        },
      }));
    });

    // キャッシュ結果と新規結果をマージ
    return { ...results, ...freshResults };
  },

  // キャッシュの無効化
  invalidateCache: (taskIds) => {
    set((state) => {
      const newCache = { ...state.cache };
      taskIds.forEach((id) => {
        delete newCache[id];
      });
      return { cache: newCache };
    });
  },

  // キャッシュ期限の設定変更
  setCacheTTL: (milliseconds) => {
    set((state) => ({
      cacheConfig: {
        ...state.cacheConfig,
        ttl: milliseconds,
      },
    }));
  },
}));

ポイント

依存関係のあるリクエスト処理におけるポイント:

  1. 依存グラフの明確化: タスク間の依存関係を明示的に定義し、実行順序を管理します。

  2. エラー伝播: あるリクエストが失敗した場合の影響範囲を理解し、適切に処理します。

  3. 状態管理の粒度: 各リクエストの状態(ローディング、エラー)を個別に追跡するか、グループ化するかを決定します。

  4. キャンセル戦略: 依存するリクエストがキャンセルされた場合の対処方法を定義します。

  5. 並列 vs 直列: 依存関係がない場合は並列実行し、パフォーマンスを最適化します。

実装パターンの比較

パターン利点欠点
直接的な連鎖実装が簡単、理解しやすい柔軟性に欠ける、エラー処理が複雑になりやすい
タスク管理ヘルパー依存関係の明示的な管理、柔軟性追加のユーティリティコードが必要
レベルベースの並列実行最適なパフォーマンス実装の複雑さ
キャッシュの活用不要なリクエストの削減キャッシュ無効化戦略が必要

まとめ

この記事では、Zustand を使った依存関係のあるリクエストの管理について解説しました。複雑なデータ取得シナリオでは、リクエスト間の依存関係を適切に管理することが重要です。

Zustand の柔軟な状態管理モデルを活用することで、以下のような利点があります:

  1. 透明性: リクエストの依存関係を明示的に定義できる
  2. 再利用性: 依存関係の管理ロジックを再利用可能なユーティリティとして実装できる
  3. パフォーマンス: 依存関係がないリクエストを並列実行することでパフォーマンスを最適化できる
  4. 状態管理: 各リクエストの状態を統一的に管理できる

依存関係のあるリクエストは、ユーザー認証後のデータ取得、設定に基づく動的コンテンツの読み込み、マルチステップのワークフローなど、多くのアプリケーションで必要となります。このパターンを活用することで、複雑なデータフローを整理し、堅牢なアプリケーションを構築できるでしょう。

関連リンク