T-CREATOR

Zustandでの非同期処理とfetch連携パターン(パターン1: 基本的なデータフェッチング)

Zustandでの非同期処理とfetch連携パターン(パターン1: 基本的なデータフェッチング)

Zustandで非同期処理を実装する際の基本パターンとして、データフェッチングは最も一般的なユースケースです。この記事では、Zustandを使った基本的なデータフェッチングパターンについて詳しく解説します。

基本的な導入と概要は以下を参照ください。

パターン 1: 基本的なデータフェッチング

最も一般的な非同期パターンは、コンポーネントがマウントされたときにデータを取得するというものです。Zustand を使った基本的なデータフェッチングパターンを詳しく見ていきましょう。

ユースケース: ユーザープロフィールの取得

ユーザープロフィールを取得し、表示するユースケースを考えてみましょう:

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

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

interface UserStore {
  // 状態
  user: User | null;
  isLoading: boolean;
  error: string | null;

  // アクション
  fetchUser: (id: string) => Promise<void>;
  clearUser: () => void;
}

export const useUserStore = create<UserStore>((set) => ({
  user: null,
  isLoading: false,
  error: null,

  fetchUser: async (id) => {
    // リクエスト開始前にローディング状態をセット
    set({ isLoading: true, error: null });

    try {
      // APIリクエスト
      const response = await fetch(`/api/users/${id}`);

      // エラーレスポンスのチェック
      if (!response.ok) {
        throw new Error(
          `Failed to fetch user: ${response.statusText}`
        );
      }

      // データの解析
      const userData = await response.json();

      // 成功した場合、状態を更新
      set({ user: userData, isLoading: false });
    } catch (error) {
      // エラーをキャプチャして状態に保存
      set({
        error:
          error instanceof Error
            ? error.message
            : '未知のエラーが発生しました',
        isLoading: false,
      });
    }
  },

  clearUser: () => set({ user: null, error: null }),
}));

コンポーネントとの連携

このストアを React コンポーネントで使用する例を見てみましょう:

tsx// src/components/UserProfile.tsx
import React, { useEffect } from 'react';
import { useUserStore } from '../stores/userStore';

interface UserProfileProps {
  userId: string;
}

export const UserProfile: React.FC<UserProfileProps> = ({
  userId,
}) => {
  const { user, isLoading, error, fetchUser } =
    useUserStore();

  useEffect(() => {
    // コンポーネントマウント時またはuserIdが変更されたときにデータを取得
    fetchUser(userId);

    // クリーンアップ関数
    return () => {
      useUserStore.getState().clearUser();
    };
  }, [userId]);

  if (isLoading) {
    return (
      <div className='loading-spinner'>読み込み中...</div>
    );
  }

  if (error) {
    return (
      <div className='error-message'>エラー: {error}</div>
    );
  }

  if (!user) {
    return <div>ユーザー情報がありません</div>;
  }

  return (
    <div className='user-profile'>
      <img
        src={user.avatarUrl}
        alt={`${user.name}のプロフィール画像`}
        className='avatar'
      />
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
};

ポイント

この基本パターンには、いくつかの重要なポイントがあります:

  1. ローディング状態の追跡: データ取得の開始時にローディング状態を true に設定し、完了時(成功またはエラー)に false に戻します。

  2. エラーハンドリング: エラーメッセージを状態の一部として保存し、ユーザーに表示します。

  3. クリーンアップ: コンポーネントのアンマウント時にストア状態をクリアして、不要なデータを残さないようにします。

  4. 再フェッチ条件: 依存配列(この例では[userId])を使って、いつデータを再取得するかを制御します。

実装のバリエーション

基本的なデータフェッチングパターンには、いくつかのバリエーションがあります:

1. 自動フェッチングと手動フェッチング

上記の例では、コンポーネントのマウント時に自動的にデータを取得していますが、ボタンクリックなどのユーザーアクションに応じて手動でデータを取得することもできます:

tsx// 手動フェッチングの例
const UserProfileWithButton = ({ userId }) => {
  const { user, isLoading, error, fetchUser } = useUserStore();

  return (
    <div>
      <button 
        onClick={() => fetchUser(userId)}
        disabled={isLoading}
      >
        ユーザー情報を取得
      </button>
      
      {/* ユーザー情報の表示 */}
      {user && <UserInfo user={user} />}
      
      {isLoading && <LoadingIndicator />}
      {error && <ErrorMessage message={error} />}
    </div>
  );
};

2. 初期データの提供

ページの初期ロード時にサーバーからデータを受け取る場合、それを初期状態として設定できます:

typescript// 初期データを受け入れるストア
const useUserStore = create<UserStore>((set) => ({
  user: null, // 後で初期化可能
  isLoading: false,
  error: null,
  
  initializeWithData: (initialData) => {
    set({ user: initialData });
  },
  
  // その他のアクション
}));

// 使用例(Next.jsなどのSSRフレームワークで)
export const UserProfileWithInitialData = ({ initialUserData }) => {
  const { user, initializeWithData } = useUserStore();
  
  useEffect(() => {
    if (initialUserData) {
      initializeWithData(initialUserData);
    }
  }, [initialUserData]);
  
  // 以下、通常のレンダリングロジック
};

3. 条件付きフェッチング

特定の条件が満たされた場合にのみデータを取得する例:

typescriptconst useConditionalFetchStore = create((set, get) => ({
  data: null,
  isLoading: false,
  error: null,
  lastFetched: null,
  
  fetchIfNeeded: async (id) => {
    const { data, lastFetched } = get();
    const currentTime = Date.now();
    
    // 以下の場合にのみ取得:
    // 1. データがまだない
    // 2. 最後の取得から5分以上経過している
    // 3. IDが変更された
    if (
      !data || 
      !lastFetched || 
      currentTime - lastFetched > 5 * 60 * 1000 ||
      data.id !== id
    ) {
      // 通常のフェッチ処理
      set({ isLoading: true });
      try {
        const response = await fetch(`/api/data/${id}`);
        const newData = await response.json();
        set({ 
          data: newData,
          lastFetched: Date.now(),
          isLoading: false
        });
      } catch (error) {
        set({ error: error.message, isLoading: false });
      }
    }
  }
}));

まとめ

この記事では、Zustandを使った基本的なデータフェッチングパターンについて解説しました。Zustandのシンプルさと柔軟性を活かした実装により、非同期データ取得を効率的に管理できることが分かりました。

基本的なデータフェッチングパターンの重要なポイントは以下の通りです:

  1. 状態設計: データ、ローディング状態、エラー状態を適切に定義する
  2. 非同期アクション: async/await を使った直感的な実装
  3. エラーハンドリング: try/catch による包括的なエラー処理
  4. コンポーネント連携: useEffect または手動トリガーによる適切なタイミングでのデータ取得
  5. クリーンアップ: 不要になったデータの適切な処理

このパターンは、より複雑な非同期シナリオ(楽観的更新、無限スクロール、WebSocketなど)の基盤となる重要な実装方法です。

関連リンク