T-CREATOR

Zustandでの非同期処理とfetch連携パターン(基本的な導入と概要)

Zustandでの非同期処理とfetch連携パターン(基本的な導入と概要)

モダンな Web アプリケーション開発において、非同期データ取得は避けて通れない課題です。API からのデータ取得、ユーザー入力の送信、WebSocket によるリアルタイム通信など、フロントエンドは常に外部リソースとの通信を行っています。Zustand はその簡潔な API で状態管理を簡素化しますが、非同期処理においても優れた柔軟性を発揮します。この記事では、Zustand を使った非同期処理の実装パターンを、特にfetch API との連携に焦点を当てて詳しく解説します。

はじめに

React アプリケーションにおいて、非同期処理は多くの場合、ユーザー体験の質を左右する重要な要素です。データのローディング状態、エラーハンドリング、キャッシュ戦略など、適切に管理しなければならない側面が数多くあります。

Zustand における非同期処理の基本概念

Zustand は状態管理ライブラリとしてのシンプルさを売りにしていますが、この哲学は非同期処理にも反映されています。Zustand には非同期処理のための特別な仕組みはありません。これは一見すると欠点のように思えるかもしれませんが、実はこれが最大の強みとなっています。

Zustand では、非同期アクションも通常のアクションと同じように定義できます。違いは単に関数の内部で非同期処理を行い、処理の結果に基づいてset関数を呼び出すだけです。この単純さが、様々な非同期処理パターンを柔軟に実装できる基盤となっています。

typescriptimport { create } from 'zustand';

// 基本的な非同期アクションを持つストア
const useUserStore = create((set) => ({
  user: null,
  isLoading: false,
  error: null,

  // 非同期アクション
  fetchUser: async (id) => {
    set({ isLoading: true, error: null });
    try {
      const response = await fetch(`/api/users/${id}`);
      if (!response.ok) throw new Error('User not found');
      const user = await response.json();
      set({ user, isLoading: false });
    } catch (error) {
      set({ error: error.message, isLoading: false });
    }
  },
}));

なぜ Zustand で非同期処理が簡単なのか

他の状態管理ライブラリと比較すると、Zustand の非同期処理の簡潔さがより明確になります。

  1. ボイラープレートの削減: Redux では Action Creator、Action Types、Reducer など多くの要素が必要ですが、Zustand ではアクション内で直接状態を更新できます。

  2. ミドルウェアの不要性: Redux ではredux-thunkredux-sagaなどのミドルウェアが必要ですが、Zustand では標準で非同期処理をサポートしています。

  3. 柔軟性: 特定のパターンに縛られることなく、プロジェクトに最適な非同期処理パターンを自由に実装できます。

  4. 透明性: 非同期ロジックがどのように状態を更新するかが直感的に理解できます。

この柔軟さと簡潔さにより、Zustand は様々な非同期シナリオに適応できるのです。

背景

フロントエンドにおける非同期処理の重要性

モダンな Web アプリケーションは、ほぼすべてがサーバーからのデータ取得や外部 API との通信に依存しています。これらの非同期処理を適切に管理することは、以下の理由から重要です:

  1. ユーザー体験: ローディング状態を適切に表示し、エラーを明確に伝えることでユーザー体験を向上させることができます。

  2. パフォーマンス: 効率的なデータ取得と状態更新は、アプリケーションの応答性を高めます。

  3. リソース管理: 不必要なリクエストを減らし、キャッシュを活用することでネットワーク負荷を軽減できます。

フェッチングパターンの進化

Web フロントエンドにおけるデータ取得の手法は、以下のように進化してきました:

  1. XMLHttpRequest: 初期の Ajax リクエスト
  2. jQuery.ajax(): より使いやすい API
  3. Fetch API: モダンなプロミスベースの API
  4. Axios: より多機能な HTTP クライアント
  5. React Query, SWR: データ取得に特化したライブラリ
  6. GraphQL (Apollo, Relay): より効率的なデータ取得のためのクエリ言語

これらの進化に伴い、状態管理との統合も進化してきました。Zustand はこの進化の流れに乗り、シンプルでありながら強力なアプローチを提供しています。

Zustand の非同期処理に対するアプローチ

Zustand は「意見を押し付けない(unopinionated)」アプローチを採用しています。つまり、非同期処理の実装方法について特定のパターンを強制せず、開発者が状況に応じて最適な方法を選択できるようにしています。

これにより、以下のような利点があります:

  1. 学習の容易さ: 新しい概念やパターンを覚える必要がありません。
  2. 統合の容易さ: 既存の fetch ロジックを簡単に Zustand に移行できます。
  3. 拡張性: 必要に応じて複雑な非同期パターンを実装できます。

課題

非同期状態の管理の複雑さ

非同期処理を扱う際の主な課題は、データだけでなく処理の状態も管理する必要があることです。典型的な非同期操作では、少なくとも以下の状態を追跡する必要があります:

  1. ローディング状態: リクエストが進行中かどうか
  2. 成功状態: データが正常に取得できたかどうか
  3. エラー状態: エラーが発生したかどうか、どのようなエラーか
  4. データ: 取得したデータ自体

これらの状態を一貫して管理することは、コードの複雑さを増大させる可能性があります。

ローディング/エラー状態の一貫した処理

アプリケーション全体で一貫したローディングとエラー処理を実装することは難しい課題です:

  1. 粒度: アプリケーション全体、ページレベル、コンポーネントレベルのどのレベルでローディング状態を管理すべきか?
  2. 表示: ローディングやエラーをどのように視覚的に表現するか?
  3. リカバリー: エラー発生時にユーザーにどのような回復手段を提供するか?

キャッシュとデータの鮮度のバランス

キャッシュを導入すると、別の課題が生じます:

  1. キャッシュの期限: データをどのくらいの期間キャッシュするか?
  2. 検証戦略: キャッシュされたデータの鮮度をどのように確認するか?
  3. 無効化: いつ、どのようにキャッシュを無効にするか?

パフォーマンスと再レンダリングの最適化

状態更新によって不必要な再レンダリングが発生すると、アプリケーションのパフォーマンスが低下する可能性があります:

  1. 粒度の最適化: 状態を適切に分割して、必要なコンポーネントだけが再レンダリングされるようにする
  2. バッチ更新: 複数の状態更新を一度にまとめて行う
  3. 選択的サブスクリプション: コンポーネントが必要な状態だけをサブスクライブする

解決策

Zustand での非同期アクションの基本実装

Zustand での非同期アクションの実装は非常に直感的です。以下のパターンが基本となります:

typescriptimport { create } from 'zustand';

const useStore = create((set) => ({
  data: null,
  isLoading: false,
  error: null,

  fetchData: async () => {
    // 1. ローディング状態を設定
    set({ isLoading: true, error: null });

    try {
      // 2. データを非同期で取得
      const response = await fetch('/api/data');
      const data = await response.json();

      // 3. 成功時の状態更新
      set({ data, isLoading: false });
    } catch (error) {
      // 4. エラー時の状態更新
      set({ error: error.message, isLoading: false });
    }
  },
}));

このパターンは単純ですが、多くの場面で効果的です。コンポーネント側では以下のように使用します:

jsxfunction DataComponent() {
  const { data, isLoading, error, fetchData } = useStore();

  useEffect(() => {
    fetchData();
  }, []);

  if (isLoading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error}</div>;
  if (!data) return null;

  return <div>{/* データを表示 */}</div>;
}

非同期状態の設計パターン

パターン 1: 個別状態トラッキング

各非同期操作ごとに独立した状態を持つパターンです:

typescriptconst useStore = create((set) => ({
  // ユーザーデータ関連
  users: [],
  usersLoading: false,
  usersError: null,

  // 商品データ関連
  products: [],
  productsLoading: false,
  productsError: null,

  // アクション
  fetchUsers: async () => {
    set({ usersLoading: true, usersError: null });
    try {
      const response = await fetch('/api/users');
      const users = await response.json();
      set({ users, usersLoading: false });
    } catch (error) {
      set({
        usersError: error.message,
        usersLoading: false,
      });
    }
  },

  fetchProducts: async () => {
    set({ productsLoading: true, productsError: null });
    try {
      const response = await fetch('/api/products');
      const products = await response.json();
      set({ products, productsLoading: false });
    } catch (error) {
      set({
        productsError: error.message,
        productsLoading: false,
      });
    }
  },
}));

パターン 2: リクエスト状態オブジェクト

リクエスト状態をオブジェクトとしてグループ化するパターンです:

typescriptconst useStore = create((set) => ({
  requests: {
    users: { status: 'idle', error: null },
    products: { status: 'idle', error: null },
  },
  data: {
    users: [],
    products: [],
  },

  fetchUsers: async () => {
    set((state) => ({
      requests: {
        ...state.requests,
        users: { status: 'loading', error: null },
      },
    }));

    try {
      const response = await fetch('/api/users');
      const users = await response.json();

      set((state) => ({
        data: { ...state.data, users },
        requests: {
          ...state.requests,
          users: { status: 'success', error: null },
        },
      }));
    } catch (error) {
      set((state) => ({
        requests: {
          ...state.requests,
          users: { status: 'error', error: error.message },
        },
      }));
    }
  },
  // 同様にfetchProductsも実装
}));

ミドルウェアを活用した非同期処理の拡張

Zustand のミドルウェアを使用して、非同期処理をさらに強化できます:

Immer による状態更新の簡素化

Immer ミドルウェアを使用すると、ネストされた状態の更新がより簡潔になります:

typescriptimport { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

const useStore = create(
  immer((set) => ({
    entities: {
      users: {},
      comments: {},
    },
    requests: {
      users: { loading: false, error: null },
      comments: { loading: false, error: null },
    },

    fetchUsers: async () => {
      // Immerを使うと直接状態を変更できる(イミュータブルに変換される)
      set((state) => {
        state.requests.users.loading = true;
        state.requests.users.error = null;
      });

      try {
        const response = await fetch('/api/users');
        const users = await response.json();

        set((state) => {
          // ユーザーをIDでインデックス化
          users.forEach((user) => {
            state.entities.users[user.id] = user;
          });
          state.requests.users.loading = false;
        });
      } catch (error) {
        set((state) => {
          state.requests.users.loading = false;
          state.requests.users.error = error.message;
        });
      }
    },
  }))
);

Persist によるオフライン対応

非同期データをローカルストレージに永続化してオフライン対応を強化できます:

typescriptimport { create } from 'zustand';
import { persist } from 'zustand/middleware';

const useStore = create(
  persist(
    (set, get) => ({
      users: [],
      isLoading: false,
      error: null,
      lastUpdated: null,

      fetchUsers: async () => {
        set({ isLoading: true, error: null });
        try {
          const response = await fetch('/api/users');
          const users = await response.json();
          set({
            users,
            isLoading: false,
            lastUpdated: new Date().toISOString(),
          });
        } catch (error) {
          set({ error: error.message, isLoading: false });
        }
      },
    }),
    {
      name: 'users-storage', // ストレージのキー
      partialize: (state) => ({
        users: state.users,
        lastUpdated: state.lastUpdated,
      }), // 永続化する部分状態を指定
    }
  )
);

デバウンスとスロットリングの実装

ユーザー入力に基づく非同期処理では、デバウンスやスロットリングが重要です:

typescriptimport { create } from 'zustand';

// ヘルパー関数:デバウンス
const debounce = (fn, delay) => {
  let timeoutId;
  return (...args) => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn(...args), delay);
  };
};

const useSearchStore = create((set, get) => {
  // 内部で使用するデバウンス済み関数
  const debouncedSearch = debounce(async (query) => {
    if (!query) {
      set({ results: [], isLoading: false });
      return;
    }

    set({ isLoading: true, error: null });
    try {
      const response = await fetch(
        `/api/search?q=${encodeURIComponent(query)}`
      );
      const results = await response.json();
      // 現在のクエリと一致する場合のみ更新(競合状態の防止)
      if (query === get().query) {
        set({ results, isLoading: false });
      }
    } catch (error) {
      set({ error: error.message, isLoading: false });
    }
  }, 300); // 300ms待機

  return {
    query: '',
    results: [],
    isLoading: false,
    error: null,

    // ユーザーがこの関数を呼び出す
    setQuery: (query) => {
      set({ query });
      debouncedSearch(query);
    },
  };
});

このように Zustand は様々な非同期処理パターンに対応できる柔軟性を提供しています。

まとめ

この記事では、Zustand を使った非同期処理の基本概念と実装パターンについて解説しました。Zustand の特徴である柔軟性とシンプルさを活かした実装方法として、以下のポイントが重要です:

  1. シンプルな非同期アクション設計: async/await 構文を使って直感的に非同期処理を実装できます。

  2. 状態管理の一貫性: データ、ローディング状態、エラー状態を一元管理することで、UI の一貫性を保てます。

  3. 柔軟なパターン適用: 状況に応じて個別状態管理やリクエスト状態オブジェクトなど、適切なパターンを選択できます。

  4. ミドルウェア活用: Immer や persist などのミドルウェアを活用することで、より堅牢な実装が可能です。

  5. ユーザー体験の向上: デバウンスやスロットリングなどのパターンにより、アプリケーションのレスポンシブ性を高められます。

Zustand の「意見を押し付けない」アプローチは、プロジェクトやチームに最適な非同期パターンを自由に選択し、実装できる大きな強みです。この記事で紹介した基本パターンをベースに、あなたのアプリケーションに最適な実装を見つけてください。

関連リンク