T-CREATOR

Zustand × TanStack Query × SWR:キャッシュ・再検証・型安全の実運用比較

Zustand × TanStack Query × SWR:キャッシュ・再検証・型安全の実運用比較

React アプリケーションの状態管理とデータフェッチングにおいて、Zustand、TanStack Query、SWR は現在最も注目されているライブラリです。それぞれが異なるアプローチでアプリケーションの課題を解決しますが、実際のプロジェクトでどれを選ぶべきか迷われる方も多いでしょう。

本記事では、これら 3 つのライブラリについて、キャッシュ戦略、再検証メカニズム、型安全性の観点から詳細に比較検証します。実運用での性能評価も含めて、あなたのプロジェクトに最適な選択肢を見つけるための指針をお届けします。

ライブラリ概要と特徴

Zustand の特徴と適用場面

Zustand は、シンプルさと軽量性を重視した状態管理ライブラリです。Redux のような複雑な設定や boilerplate コードを排除し、関数型アプローチで直感的な状態管理を実現しています。

以下の図は、Zustand の基本的なアーキテクチャを示しています。

mermaidflowchart LR
  component["React Component"] -->|useStore| store["Zustand Store"]
  store -->|状態更新| component
  store -->|永続化| storage["LocalStorage"]
  storage -->|復元| store

Zustand の核となる特徴は以下の通りです。

軽量性とシンプルさ

typescriptimport { create } from 'zustand';

interface UserState {
  user: User | null;
  setUser: (user: User) => void;
  clearUser: () => void;
}

const useUserStore = create<UserState>((set) => ({
  user: null,
  setUser: (user) => set({ user }),
  clearUser: () => set({ user: null }),
}));

型安全性の確保

typescript// TypeScript との親和性が高く、型推論が自動的に働きます
const UserProfile = () => {
  const { user, setUser } = useUserStore();
  // user の型は User | null として推論される

  return (
    <div>
      {user?.name} {/* 型安全なアクセス */}
    </div>
  );
};

適用場面

Zustand は以下のようなケースで特に威力を発揮します。

  • シンプルなグローバル状態管理が必要な場合
  • Redux の学習コストを避けたい小〜中規模プロジェクト
  • TypeScript プロジェクトでの型安全な状態管理
  • ライブラリサイズを抑えたいモバイル向けアプリケーション

TanStack Query の特徴と適用場面

TanStack Query(旧 React Query)は、サーバー状態の管理に特化したライブラリです。データフェッチング、キャッシュ、同期、更新処理を包括的に解決し、クライアント側でのサーバー状態管理のベストプラクティスを提供します。

以下の図は、TanStack Query のデータフロー全体を示しています。

mermaidflowchart TD
  component["React Component"] -->|useQuery| query["Query Hook"]
  query -->|fetch| api["API Server"]
  api -->|response| cache["Query Cache"]
  cache -->|cached data| component
  cache -->|background refetch| api
  query -->|invalidate| cache
  cache -->|garbage collection| memory["Memory"]

TanStack Query の主要な特徴は以下の通りです。

強力なキャッシュシステム

typescriptimport { useQuery } from '@tanstack/react-query';

interface Product {
  id: number;
  name: string;
  price: number;
}

const ProductList = () => {
  const { data, isLoading, error } = useQuery({
    queryKey: ['products'],
    queryFn: () =>
      fetch('/api/products').then((res) => res.json()),
    staleTime: 5 * 60 * 1000, // 5分間フレッシュ状態を維持
    cacheTime: 10 * 60 * 1000, // 10分間キャッシュを保持
  });

  if (isLoading) return <div>読み込み中...</div>;
  if (error) return <div>エラーが発生しました</div>;

  return (
    <ul>
      {data?.map((product: Product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
};

楽観的更新とミューテーション

typescriptimport {
  useMutation,
  useQueryClient,
} from '@tanstack/react-query';

const useUpdateProduct = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (product: Product) =>
      fetch(`/api/products/${product.id}`, {
        method: 'PUT',
        body: JSON.stringify(product),
      }),
    onMutate: async (newProduct) => {
      // 楽観的更新の実装
      await queryClient.cancelQueries({
        queryKey: ['products'],
      });
      const previousProducts = queryClient.getQueryData([
        'products',
      ]);

      queryClient.setQueryData(
        ['products'],
        (old: Product[]) =>
          old.map((p) =>
            p.id === newProduct.id ? newProduct : p
          )
      );

      return { previousProducts };
    },
    onError: (err, newProduct, context) => {
      // エラー時のロールバック
      queryClient.setQueryData(
        ['products'],
        context?.previousProducts
      );
    },
    onSettled: () => {
      queryClient.invalidateQueries({
        queryKey: ['products'],
      });
    },
  });
};

適用場面

TanStack Query は以下のシナリオで最大の効果を発揮します。

  • API を多用するデータドリブンなアプリケーション
  • リアルタイム性が重要なダッシュボードやモニタリングツール
  • 複雑なデータ関係性を持つ管理画面
  • オフライン対応が必要なプログレッシブウェブアプリケーション

SWR の特徴と適用場面

SWR(Stale-While-Revalidate)は、データフェッチングの戦略名そのものを冠したライブラリです。Vercel チームによって開発され、Next.js との親和性が高く、シンプルながら効果的なデータフェッチングソリューションを提供します。

以下の図は、SWR の Stale-While-Revalidate 戦略を図解しています。

mermaidsequenceDiagram
  participant C as Component
  participant S as SWR
  participant Cache
  participant API

  C->>S: データ要求
  S->>Cache: キャッシュ確認
  Cache-->>S: Stale データ返却
  S-->>C: Stale データ表示(即座に)
  S->>API: バックグラウンド再検証
  API-->>S: 最新データ
  S->>Cache: キャッシュ更新
  S-->>C: 最新データで再レンダリング

SWR のコア機能は以下の通りです。

シンプルなデータフェッチング

typescriptimport useSWR from 'swr';

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

const fetcher = (url: string) =>
  fetch(url).then((res) => res.json());

const UserProfile = ({ userId }: { userId: number }) => {
  const { data, error, isLoading } = useSWR<User>(
    `/api/users/${userId}`,
    fetcher
  );

  if (error) return <div>読み込みに失敗しました</div>;
  if (isLoading) return <div>読み込み中...</div>;

  return (
    <div>
      <h1>{data?.name}</h1>
      <p>{data?.email}</p>
    </div>
  );
};

自動再検証とフォーカス管理

typescriptimport useSWR from 'swr';

const Dashboard = () => {
  const { data, mutate } = useSWR(
    '/api/dashboard',
    fetcher,
    {
      refreshInterval: 1000, // 1秒ごとにポーリング
      revalidateOnFocus: true, // ウィンドウフォーカス時に再検証
      revalidateOnReconnect: true, // ネットワーク再接続時に再検証
    }
  );

  // 手動でデータを再取得
  const handleRefresh = () => {
    mutate();
  };

  return (
    <div>
      <button onClick={handleRefresh}>更新</button>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
};

条件付きフェッチング

typescriptconst ConditionalFetch = ({
  shouldFetch,
  userId,
}: {
  shouldFetch: boolean;
  userId?: number;
}) => {
  // shouldFetch が false または userId が undefined の場合、
  // フェッチを実行しない
  const { data } = useSWR(
    shouldFetch && userId ? `/api/users/${userId}` : null,
    fetcher
  );

  return <div>{data?.name || '未選択'}</div>;
};

適用場面

SWR は以下のようなケースで特に有効です。

  • Next.js プロジェクトでのデータフェッチング
  • シンプルなデータ取得ロジックが中心のアプリケーション
  • リアルタイム更新が重要な情報表示画面
  • 学習コストを抑えたい小規模チーム開発

キャッシュ戦略の比較

キャッシュの仕組みとライフサイクル

各ライブラリのキャッシュ戦略には大きな違いがあります。それぞれのアプローチを詳しく見ていきましょう。

Zustand のキャッシュアプローチ

Zustand は基本的にクライアント状態管理に特化しており、従来的な意味でのキャッシュ機能は提供していません。しかし、永続化ミドルウェアを使用することで、状態の永続保存が可能です。

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

interface AppState {
  userSettings: UserSettings;
  updateSettings: (settings: Partial<UserSettings>) => void;
}

const useAppStore = create<AppState>()(
  persist(
    (set) => ({
      userSettings: { theme: 'light', language: 'ja' },
      updateSettings: (settings) =>
        set((state) => ({
          userSettings: {
            ...state.userSettings,
            ...settings,
          },
        })),
    }),
    {
      name: 'app-storage', // localStorage キー名
      storage: {
        getItem: (name) => {
          const value = localStorage.getItem(name);
          return value ? JSON.parse(value) : null;
        },
        setItem: (name, value) => {
          localStorage.setItem(name, JSON.stringify(value));
        },
        removeItem: (name) => localStorage.removeItem(name),
      },
    }
  )
);

TanStack Query の多層キャッシュシステム

TanStack Query は、最も洗練されたキャッシュシステムを提供しています。以下の図は、そのキャッシュライフサイクルを示しています。

mermaidstateDiagram-v2
  [*] --> fresh: データ取得
  fresh --> stale: staleTime 経過
  stale --> fetching: バックグラウンド再取得
  fetching --> fresh: 新しいデータ取得
  stale --> inactive: コンポーネントアンマウント
  inactive --> garbage: cacheTime 経過
  garbage --> [*]: メモリから削除
typescriptimport {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';

// グローバル設定でキャッシュ戦略を定義
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5分間は fresh 状態
      cacheTime: 30 * 60 * 1000, // 30分間キャッシュ保持
      retry: 3, // 失敗時は3回まで再試行
      retryDelay: (attemptIndex) =>
        Math.min(1000 * 2 ** attemptIndex, 30000),
    },
  },
});

// 特定のクエリごとに詳細なキャッシュ設定
const ProductDetail = ({
  productId,
}: {
  productId: number;
}) => {
  const { data } = useQuery({
    queryKey: ['product', productId],
    queryFn: () => fetchProduct(productId),
    staleTime: 10 * 60 * 1000, // 商品情報は10分間 fresh
    cacheTime: 60 * 60 * 1000, // 1時間キャッシュ保持
    select: (data) => ({
      // データ変換とメモ化
      ...data,
      formattedPrice: new Intl.NumberFormat('ja-JP', {
        style: 'currency',
        currency: 'JPY',
      }).format(data.price),
    }),
  });

  return <div>{data?.formattedPrice}</div>;
};

SWR のシンプルキャッシュモデル

SWR は Stale-While-Revalidate 戦略に基づいたシンプルなキャッシュを提供します。

typescriptimport { SWRConfig } from 'swr';

// アプリ全体の SWR 設定
const App = () => (
  <SWRConfig
    value={{
      refreshInterval: 3000, // 3秒ごとにポーリング
      dedupingInterval: 2000, // 2秒以内の重複リクエストを無視
      focusThrottleInterval: 5000, // フォーカス時再検証の間隔制限
      errorRetryCount: 3, // エラー時の再試行回数
      errorRetryInterval: 5000, // 再試行間隔
      cache: new Map(), // カスタムキャッシュプロバイダー
    }}
  >
    <MyApp />
  </SWRConfig>
);

// ページレベルでのキャッシュ制御
const ProductPage = () => {
  const { data, mutate } = useSWR(
    '/api/products',
    fetcher,
    {
      revalidateOnMount: true, // マウント時に常に再検証
      revalidateIfStale: true, // stale 状態でも再検証実行
      revalidateOnFocus: false, // フォーカス時再検証を無効化
    }
  );

  // 手動でキャッシュをクリア
  const clearCache = () => {
    mutate(undefined, { revalidate: false });
  };

  return (
    <div>
      <button onClick={clearCache}>キャッシュクリア</button>
      {/* 商品一覧の表示 */}
    </div>
  );
};

パフォーマンスへの影響

レンダリング最適化の比較

各ライブラリがレンダリングパフォーマンスに与える影響を検証してみましょう。

typescript// Zustand: 選択的購読によるレンダリング最適化
const OptimizedZustandComponent = () => {
  // 必要な状態のみを選択的に購読
  const userName = useUserStore(
    (state) => state.user?.name
  );
  const userEmail = useUserStore(
    (state) => state.user?.email
  );

  // userSettings が変更されてもこのコンポーネントは再レンダリングされない
  return (
    <div>
      {userName} ({userEmail})
    </div>
  );
};

// TanStack Query: 自動的なレンダリング最適化
const OptimizedQueryComponent = () => {
  const { data } = useQuery({
    queryKey: ['user-profile'],
    queryFn: fetchUserProfile,
    select: (data) => ({
      // 必要なフィールドのみを選択
      displayName: data.firstName + ' ' + data.lastName,
      avatar: data.profileImage,
    }),
  });

  // select で変換されたデータが変更された場合のみ再レンダリング
  return <div>{data?.displayName}</div>;
};

// SWR: 条件付きレンダリング最適化
const OptimizedSWRComponent = ({
  userId,
}: {
  userId?: number;
}) => {
  const { data } = useSWR(
    userId ? `/api/users/${userId}` : null, // 条件付きフェッチング
    fetcher,
    {
      compare: (a, b) => {
        // カスタム比較関数でレンダリングを制御
        return a?.lastModified === b?.lastModified;
      },
    }
  );

  return <div>{data?.name}</div>;
};

メモリ使用量と効率性

メモリ効率性の比較表

ライブラリバンドルサイズメモリ使用量ガベージコレクション
Zustand2.9KB (gzipped)軽量(状態のみ)手動管理が必要
TanStack Query39KB (gzipped)中程度(自動管理)自動的なクリーンアップ
SWR4.2KB (gzipped)軽量(シンプル設計)タイムアウトベース

メモリリーク対策の実装例

typescript// Zustand: 手動でのクリーンアップ
const useAutoCleanup = () => {
  const store = useUserStore();

  useEffect(() => {
    return () => {
      // コンポーネントアンマウント時にストアをクリア
      store.clearUser();
    };
  }, []);
};

// TanStack Query: 自動的なガベージコレクション
const QueryWithGarbageCollection = () => {
  const { data } = useQuery({
    queryKey: ['large-dataset'],
    queryFn: fetchLargeDataset,
    cacheTime: 5 * 60 * 1000, // 5分後に自動削除
    onSuccess: (data) => {
      // 成功時のメモリ最適化
      if (data.length > 1000) {
        console.warn(
          '大量データを取得しました。キャッシュ時間を短縮します。'
        );
      }
    },
  });

  return <DataVisualization data={data} />;
};

// SWR: カスタムキャッシュプロバイダーでメモリ制御
const memoryEfficientCache = new Map();

// 最大キャッシュサイズを制限
const MAX_CACHE_SIZE = 100;

const customCache = {
  get: (key: string) => memoryEfficientCache.get(key),
  set: (key: string, value: any) => {
    if (memoryEfficientCache.size >= MAX_CACHE_SIZE) {
      // LRU で最も古いエントリを削除
      const firstKey = memoryEfficientCache
        .keys()
        .next().value;
      memoryEfficientCache.delete(firstKey);
    }
    memoryEfficientCache.set(key, value);
  },
  delete: (key: string) => memoryEfficientCache.delete(key),
};

再検証メカニズムの深掘り

リアルタイム更新の実装

WebSocket を使用したリアルタイム更新

各ライブラリでリアルタイム更新を実装する方法を比較してみましょう。

typescript// Zustand: WebSocket イベントリスナーで状態更新
interface RealtimeState {
  notifications: Notification[];
  onlineUsers: User[];
  addNotification: (notification: Notification) => void;
  updateOnlineUsers: (users: User[]) => void;
}

const useRealtimeStore = create<RealtimeState>((set) => {
  // WebSocket 接続の初期化
  const ws = new WebSocket(
    'wss://api.example.com/realtime'
  );

  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);

    switch (data.type) {
      case 'NOTIFICATION':
        set((state) => ({
          notifications: [
            ...state.notifications,
            data.payload,
          ],
        }));
        break;
      case 'USERS_UPDATE':
        set({ onlineUsers: data.payload });
        break;
    }
  };

  return {
    notifications: [],
    onlineUsers: [],
    addNotification: (notification) =>
      set((state) => ({
        notifications: [
          ...state.notifications,
          notification,
        ],
      })),
    updateOnlineUsers: (users) =>
      set({ onlineUsers: users }),
  };
});
typescript// TanStack Query: invalidation によるリアルタイム同期
const useRealtimeInvalidation = () => {
  const queryClient = useQueryClient();

  useEffect(() => {
    const ws = new WebSocket(
      'wss://api.example.com/realtime'
    );

    ws.onmessage = (event) => {
      const data = JSON.parse(event.data);

      switch (data.type) {
        case 'USER_UPDATED':
          // 特定ユーザーのクエリを無効化
          queryClient.invalidateQueries({
            queryKey: ['user', data.userId],
          });
          break;
        case 'GLOBAL_UPDATE':
          // 関連するすべてのクエリを無効化
          queryClient.invalidateQueries({
            predicate: (query) =>
              query.queryKey[0] === 'users' ||
              query.queryKey[0] === 'notifications',
          });
          break;
      }
    };

    return () => ws.close();
  }, [queryClient]);
};

// 楽観的更新を含むリアルタイム対応
const useOptimisticMessage = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (message: NewMessage) => {
      return api.sendMessage(message);
    },
    onMutate: async (newMessage) => {
      // 既存のクエリをキャンセル
      await queryClient.cancelQueries({
        queryKey: ['messages'],
      });

      const previousMessages = queryClient.getQueryData([
        'messages',
      ]);

      // 楽観的更新
      queryClient.setQueryData(
        ['messages'],
        (old: Message[]) => [
          ...old,
          {
            ...newMessage,
            id: 'temp-' + Date.now(),
            status: 'sending',
          },
        ]
      );

      return { previousMessages };
    },
    onSuccess: (data, newMessage) => {
      // WebSocket で受信した実際のデータに更新
      queryClient.setQueryData(
        ['messages'],
        (old: Message[]) =>
          old.map((msg) =>
            msg.id.startsWith('temp-') &&
            msg.content === newMessage.content
              ? data
              : msg
          )
      );
    },
    onError: (err, newMessage, context) => {
      // エラー時のロールバック
      queryClient.setQueryData(
        ['messages'],
        context?.previousMessages
      );
    },
  });
};
typescript// SWR: mutate を使用したリアルタイム更新
const useRealtimeSWR = () => {
  const { data: messages, mutate } = useSWR(
    '/api/messages',
    fetcher
  );

  useEffect(() => {
    const ws = new WebSocket(
      'wss://api.example.com/messages'
    );

    ws.onmessage = (event) => {
      const newMessage = JSON.parse(event.data);

      // 楽観的更新(サーバーからの再取得なし)
      mutate(
        (currentMessages: Message[]) => [
          ...currentMessages,
          newMessage,
        ],
        false
      );
    };

    return () => ws.close();
  }, [mutate]);

  return { messages };
};

// 条件付きリアルタイム更新
const ConditionalRealtimeUpdate = ({
  isActive,
}: {
  isActive: boolean;
}) => {
  const { data, mutate } = useSWR(
    isActive ? '/api/active-data' : null,
    fetcher,
    {
      refreshInterval: isActive ? 1000 : 0, // アクティブ時のみポーリング
      revalidateOnFocus: isActive,
    }
  );

  // 手動でのリアルタイム更新制御
  const toggleRealtime = useCallback(() => {
    if (isActive) {
      mutate(); // 即座に更新
    }
  }, [isActive, mutate]);

  return (
    <div>
      <button onClick={toggleRealtime}>手動更新</button>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
};

バックグラウンド再検証

各ライブラリのバックグラウンド処理比較

mermaidsequenceDiagram
  participant UI as User Interface
  participant Z as Zustand
  participant TQ as TanStack Query
  participant S as SWR
  participant API as API Server

  Note over UI,API: ユーザーがページを離れた場合

  UI->>Z: フォーカス離脱
  Note over Z: バックグラウンド処理なし

  UI->>TQ: フォーカス離脱
  TQ->>API: バックグラウンド再検証継続
  API-->>TQ: 最新データ
  TQ->>TQ: キャッシュ更新

  UI->>S: フォーカス離脱
  S->>API: ポーリング継続(設定による)
  API-->>S: 最新データ
  S->>S: キャッシュ更新

  Note over UI,API: ユーザーがページに戻った場合

  UI->>Z: フォーカス復帰
  Note over Z: 手動更新が必要

  UI->>TQ: フォーカス復帰
  TQ-->>UI: 最新キャッシュデータ表示

  UI->>S: フォーカス復帰
  S->>API: フォーカス時再検証
  API-->>S: 確認・更新
  S-->>UI: 最新データ表示

バックグラウンド同期の実装例

typescript// TanStack Query: 高度なバックグラウンド同期
const BackgroundSyncComponent = () => {
  const { data, isLoading } = useQuery({
    queryKey: ['dashboard-data'],
    queryFn: fetchDashboardData,
    refetchInterval: (data, query) => {
      // 動的な更新間隔の設定
      if (query.state.error) {
        return 10000; // エラー時は10秒間隔
      }
      if (data?.priority === 'high') {
        return 2000; // 高優先度データは2秒間隔
      }
      return 5000; // 通常は5秒間隔
    },
    refetchIntervalInBackground: true, // バックグラウンドでも継続
    staleTime: 0, // 常に stale として扱い、積極的に更新
  });

  return <DashboardView data={data} loading={isLoading} />;
};

// ネットワーク状態を考慮したバックグラウンド同期
const NetworkAwareSync = () => {
  const { data } = useQuery({
    queryKey: ['network-sensitive-data'],
    queryFn: fetchSensitiveData,
    networkMode: 'offlineFirst', // オフライン時はキャッシュを優先
    retry: (failureCount, error) => {
      // ネットワークエラーの場合のみリトライ
      return (
        error.message.includes('network') &&
        failureCount < 3
      );
    },
    retryDelay: (attemptIndex) => {
      // 指数バックオフ + ジッター
      const baseDelay = Math.min(
        1000 * 2 ** attemptIndex,
        30000
      );
      const jitter = Math.random() * 0.1 * baseDelay;
      return baseDelay + jitter;
    },
  });

  return <NetworkSensitiveComponent data={data} />;
};
typescript// SWR: フォーカスベースの再検証制御
const FocusAwareComponent = () => {
  const { data, mutate } = useSWR(
    '/api/focus-sensitive',
    fetcher,
    {
      revalidateOnFocus: true,
      focusThrottleInterval: 5000, // 5秒以内の連続フォーカスを無視
      dedupingInterval: 2000, // 2秒以内の重複リクエストを統合
    }
  );

  // ページ可視性の変化を監視
  useEffect(() => {
    const handleVisibilityChange = () => {
      if (document.visibilityState === 'visible') {
        // ページが見えるようになったら手動で再検証
        mutate();
      }
    };

    document.addEventListener(
      'visibilitychange',
      handleVisibilityChange
    );
    return () => {
      document.removeEventListener(
        'visibilitychange',
        handleVisibilityChange
      );
    };
  }, [mutate]);

  return <div>{data?.content}</div>;
};

// バッテリー状況を考慮した省電力モード
const BatteryAwarePolling = () => {
  const [isLowBattery, setIsLowBattery] = useState(false);

  useEffect(() => {
    if ('getBattery' in navigator) {
      navigator.getBattery().then((battery: any) => {
        const updateBatteryInfo = () => {
          setIsLowBattery(
            battery.level < 0.2 && !battery.charging
          );
        };

        battery.addEventListener(
          'levelchange',
          updateBatteryInfo
        );
        battery.addEventListener(
          'chargingchange',
          updateBatteryInfo
        );
        updateBatteryInfo();
      });
    }
  }, []);

  const { data } = useSWR('/api/battery-aware', fetcher, {
    refreshInterval: isLowBattery ? 30000 : 5000, // 低バッテリー時は更新頻度を下げる
    revalidateOnFocus: !isLowBattery, // 低バッテリー時はフォーカス再検証を無効化
  });

  return (
    <div>
      {isLowBattery && <div>省電力モードで動作中</div>}
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
};

エラーハンドリングと再試行

包括的なエラーハンドリング戦略

typescript// TanStack Query: 詳細なエラーハンドリング
const RobustErrorHandling = () => {
  const { data, error, isError, failureCount } = useQuery({
    queryKey: ['robust-data'],
    queryFn: async () => {
      const response = await fetch('/api/data');

      if (!response.ok) {
        // HTTP エラーの詳細を含む例外を投げる
        throw new Error(
          `HTTP ${response.status}: ${response.statusText}`
        );
      }

      return response.json();
    },
    retry: (failureCount, error) => {
      // 4xx エラーは再試行しない
      if (error.message.includes('HTTP 4')) {
        return false;
      }
      // 最大3回まで再試行
      return failureCount < 3;
    },
    retryDelay: (attemptIndex) => {
      // 指数バックオフ戦略
      return Math.min(1000 * 2 ** attemptIndex, 30000);
    },
    onError: (error) => {
      // エラーログとユーザー通知
      console.error('データ取得エラー:', error);

      // エラー種別に応じた処理
      if (error.message.includes('HTTP 401')) {
        // 認証エラーの場合、ログイン画面にリダイレクト
        window.location.href = '/login';
      } else if (error.message.includes('HTTP 503')) {
        // サービス利用不可の場合、メンテナンス画面を表示
        showMaintenanceModal();
      }
    },
  });

  if (isError) {
    return (
      <ErrorBoundary
        error={error}
        retryCount={failureCount}
      >
        <div>
          エラーが発生しました: {error.message}
          {failureCount > 0 && (
            <div>再試行回数: {failureCount}</div>
          )}
        </div>
      </ErrorBoundary>
    );
  }

  return <DataDisplay data={data} />;
};
typescript// SWR: エラー時のフォールバック戦略
const SWRErrorHandling = () => {
  const { data, error, isValidating, mutate } = useSWR(
    '/api/unreliable-endpoint',
    fetcher,
    {
      errorRetryCount: 3,
      errorRetryInterval: 5000,
      onError: (error, key) => {
        // エラー発生時のログ記録
        console.error(`SWR Error for ${key}:`, error);

        // エラー通知サービスに送信
        errorReportingService.captureException(error, {
          context: { swrKey: key },
        });
      },
      onErrorRetry: (
        error,
        key,
        config,
        revalidate,
        { retryCount }
      ) => {
        // ネットワークエラーのみ再試行
        if (error.status === 404) return;

        // 最大5回まで再試行
        if (retryCount >= 5) return;

        // 再試行間隔をだんだん長くする
        setTimeout(
          () => revalidate({ retryCount }),
          5000 * retryCount
        );
      },
      fallbackData: [], // エラー時のフォールバックデータ
    }
  );

  // エラー状態での UI 制御
  if (error && !data) {
    return (
      <div>
        <p>データの読み込みに失敗しました</p>
        <button
          onClick={() => mutate()}
          disabled={isValidating}
        >
          {isValidating ? '再試行中...' : '再試行'}
        </button>
      </div>
    );
  }

  // 部分的なエラー状態(キャッシュデータあり)
  if (error && data) {
    return (
      <div>
        <div
          style={{
            backgroundColor: '#fff3cd',
            padding: '10px',
          }}
        >
          最新データの取得に失敗しました。キャッシュデータを表示しています。
        </div>
        <DataList data={data} />
      </div>
    );
  }

  return <DataList data={data || []} />;
};
typescript// Zustand: カスタムエラーハンドリング
interface ErrorState {
  errors: Record<string, Error>;
  addError: (key: string, error: Error) => void;
  clearError: (key: string) => void;
  hasError: (key: string) => boolean;
}

const useErrorStore = create<ErrorState>((set, get) => ({
  errors: {},
  addError: (key, error) =>
    set((state) => ({
      errors: { ...state.errors, [key]: error },
    })),
  clearError: (key) =>
    set((state) => {
      const { [key]: removed, ...rest } = state.errors;
      return { errors: rest };
    }),
  hasError: (key) => key in get().errors,
}));

// エラーハンドリング付きのデータフェッチング
const useDataWithErrorHandling = (endpoint: string) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const { addError, clearError, hasError } =
    useErrorStore();

  const fetchData = useCallback(async () => {
    setLoading(true);
    clearError(endpoint);

    try {
      const response = await fetch(endpoint);

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

      const result = await response.json();
      setData(result);
    } catch (error) {
      addError(endpoint, error as Error);
    } finally {
      setLoading(false);
    }
  }, [endpoint, addError, clearError]);

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

  return {
    data,
    loading,
    error: hasError(endpoint),
    refetch: fetchData,
  };
};

型安全性の実装と比較

TypeScript 統合の品質

各ライブラリの TypeScript サポートには大きな差があります。実際のコード例を通して詳細に比較してみましょう。

Zustand の型安全性

typescript// 型定義の明示的な指定
interface UserState {
  user: User | null
  preferences: UserPreferences
  setUser: (user: User) => void
  updatePreferences: (prefs: Partial<UserPreferences>) => void
  clearUser: () => void
}

// 完全に型安全なストア定義
const useUserStore = create<UserState>((set, get) => ({
  user: null,
  preferences: {
    theme: 'light',
    notifications: true,
    language: 'ja',
  },
  setUser: (user: User) => set({ user }),
  updatePreferences: (prefs: Partial<UserPreferences>) =>
    set((state) => ({
      preferences: { ...state.preferences, ...prefs }
    })),
  clearUser: () => set({ user: null }),
}))

// 選択的購読での型安全性
const UserProfile = () => {
  // userName は string | undefined として型推論される
  const userName = useUserStore((state) => state.user?.name)

  // 複雑な選択でも型安全性が保たれる
  const userInfo = useUserStore((state) => ({
    hasUser: state.user !== null,
    displayName: state.user?.name || 'ゲスト',
    avatarUrl: state.user?.avatar || '/default-avatar.png',
  }))

  return (
    <div>
      <h1>{userInfo.displayName}</h1>
      <img src={userInfo.avatarUrl} alt="ユーザーアバター" />
    </div>
  )
}

// ジェネリクスを使用した再利用可能なストア
interface AsyncState<T> {
  data: T | null
  loading: boolean
  error: string | null
}

interface AsyncActions<T> {
  setData: (data: T) => void
  setLoading: (loading: boolean) => void
  setError: (error: string | null) => void
  reset: () => void
}

const createAsyncStore = <T>() =>
  create<AsyncState<T> & AsyncActions<T>>((set) => ({
    data: null,
    loading: false,
    error: null,
    setData: (data: T) => set({ data, error: null }),
    setLoading: (loading: boolean) => set({ loading }),
    setError: (error: string | null) => set({ error, loading: false }),
    reset: () => set({ data: null, loading: false, error: null }),
  }))

// 型安全な専用ストアの作成
const useProductStore = createAsyncStore<Product[]>()
const useUserProfileStore = createAsyncStore<UserProfile>()

TanStack Query の高度な型推論

typescript// API レスポンスの型定義
interface ApiResponse<T> {
  data: T
  message: string
  status: 'success' | 'error'
}

interface Product {
  id: number
  name: string
  price: number
  category: string
  createdAt: string
}

// 型安全なクエリ関数の定義
const fetchProducts = async (): Promise<ApiResponse<Product[]>> => {
  const response = await fetch('/api/products')
  if (!response.ok) {
    throw new Error('Failed to fetch products')
  }
  return response.json()
}

// 自動型推論を活用したクエリ
const ProductList = () => {
  const {
    data, // ApiResponse<Product[]> | undefined として推論
    isLoading,
    error // Error | null として推論
  } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
    select: (response) => ({
      // select の戻り値も自動で型推論される
      products: response.data,
      totalCount: response.data.length,
      categories: [...new Set(response.data.map(p => p.category))],
    }),
  })

  if (error) {
    // error は Error 型として認識される
    return <div>エラー: {error.message}</div>
  }

  if (isLoading) {
    return <div>読み込み中...</div>
  }

  // data は select の戻り値型として推論される
  return (
    <div>
      <h2>商品一覧 ({data?.totalCount}件)</h2>
      <div>カテゴリ: {data?.categories.join(', ')}</div>
      <ul>
        {data?.products.map((product) => (
          <li key={product.id}>
            {product.name} - ¥{product.price.toLocaleString()}
          </li>
        ))}
      </ul>
    </div>
  )
}

// ジェネリクスを使用したカスタムフック
const useTypedQuery = <T>(
  queryKey: readonly unknown[],
  queryFn: () => Promise<ApiResponse<T>>,
  options?: Omit<UseQueryOptions<ApiResponse<T>>, 'queryKey' | 'queryFn'>
) => {
  return useQuery({
    queryKey,
    queryFn,
    select: (response) => response.data, // T 型として推論される
    ...options,
  })
}

// 使用例
const UserDetail = ({ userId }: { userId: number }) => {
  const { data: user } = useTypedQuery(
    ['user', userId],
    () => fetchUser(userId) // User 型として推論される
  )

  return <div>{user?.name}</div>
}

ミューテーションでの型安全性

typescript// ミューテーション用の型定義
interface CreateProductRequest {
  name: string;
  price: number;
  categoryId: number;
}

interface UpdateProductRequest
  extends Partial<CreateProductRequest> {
  id: number;
}

// 型安全なミューテーション
const useCreateProduct = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (
      product: CreateProductRequest
    ): Promise<Product> => {
      const response = await fetch('/api/products', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(product),
      });

      if (!response.ok) {
        throw new Error('Failed to create product');
      }

      return response.json();
    },
    onSuccess: (newProduct: Product) => {
      // 既存のクエリキャッシュを更新
      queryClient.setQueryData<ApiResponse<Product[]>>(
        ['products'],
        (oldData) => {
          if (!oldData) return oldData;

          return {
            ...oldData,
            data: [...oldData.data, newProduct],
          };
        }
      );
    },
  });
};

// 楽観的更新での型安全性
const useOptimisticUpdateProduct = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (
      product: UpdateProductRequest
    ): Promise<Product> => {
      const response = await fetch(
        `/api/products/${product.id}`,
        {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(product),
        }
      );
      return response.json();
    },
    onMutate: async (
      updatedProduct: UpdateProductRequest
    ) => {
      await queryClient.cancelQueries({
        queryKey: ['products'],
      });

      const previousProducts = queryClient.getQueryData<
        ApiResponse<Product[]>
      >(['products']);

      // 型安全な楽観的更新
      queryClient.setQueryData<ApiResponse<Product[]>>(
        ['products'],
        (oldData) => {
          if (!oldData) return oldData;

          return {
            ...oldData,
            data: oldData.data.map((product) =>
              product.id === updatedProduct.id
                ? { ...product, ...updatedProduct }
                : product
            ),
          };
        }
      );

      return { previousProducts }; // 型推論される
    },
    onError: (err, updatedProduct, context) => {
      // context の型も自動推論される
      if (context?.previousProducts) {
        queryClient.setQueryData(
          ['products'],
          context.previousProducts
        );
      }
    },
  });
};

SWR の型安全な実装

typescript// 型安全な fetcher 関数
const typedFetcher = <T>(url: string): Promise<T> =>
  fetch(url).then((res) => {
    if (!res.ok) {
      throw new Error(`HTTP ${res.status}: ${res.statusText}`)
    }
    return res.json()
  })

// ジェネリクスを活用したカスタムフック
const useTypedSWR = <T>(
  key: string | (() => string | null),
  options?: SWRConfiguration<T>
) => {
  return useSWR<T, Error>(key, typedFetcher<T>, options)
}

// 使用例
interface UserProfile {
  id: number
  name: string
  email: string
  profile: {
    bio: string
    location: string
  }
}

const UserProfileComponent = ({ userId }: { userId: number }) => {
  const { data, error, mutate } = useTypedSWR<UserProfile>(
    `/api/users/${userId}`
  )

  if (error) {
    // error は Error 型として推論される
    return <div>エラー: {error.message}</div>
  }

  if (!data) {
    return <div>読み込み中...</div>
  }

  // data は UserProfile 型として推論される
  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.email}</p>
      <p>{data.profile.bio}</p>
      <p>場所: {data.profile.location}</p>
      <button onClick={() => mutate()}>更新</button>
    </div>
  )
}

// 条件付きフェッチングでの型安全性
const ConditionalUserData = ({ shouldFetch, userId }: {
  shouldFetch: boolean
  userId?: number
}) => {
  const { data } = useTypedSWR<UserProfile>(
    shouldFetch && userId ? `/api/users/${userId}` : null
  )

  // data は UserProfile | undefined として正しく推論される
  return (
    <div>
      {data ? (
        <span>{data.name}</span>
      ) : (
        <span>ユーザーが選択されていません</span>
      )}
    </div>
  )
}

型推論の精度

複雑な型推論シナリオの比較

typescript// Zustand: 深い選択での型推論
interface ComplexState {
  users: Record<string, User>;
  posts: Record<string, Post[]>;
  ui: {
    modals: Record<string, boolean>;
    loading: Record<string, boolean>;
  };
}

const useComplexStore = create<
  ComplexState & {
    addUser: (user: User) => void;
    toggleModal: (modalId: string) => void;
  }
>((set) => ({
  users: {},
  posts: {},
  ui: { modals: {}, loading: {} },
  addUser: (user) =>
    set((state) => ({
      users: { ...state.users, [user.id]: user },
    })),
  toggleModal: (modalId) =>
    set((state) => ({
      ui: {
        ...state.ui,
        modals: {
          ...state.ui.modals,
          [modalId]: !state.ui.modals[modalId],
        },
      },
    })),
}));

// 複雑な選択での正確な型推論
const ComplexSelector = ({
  userId,
}: {
  userId: string;
}) => {
  const userPosts = useComplexStore((state) => {
    const user = state.users[userId];
    const posts = state.posts[userId] || [];
    const isLoading =
      state.ui.loading[`user-${userId}`] || false;

    return {
      user, // User | undefined として推論
      posts, // Post[] として推論
      isLoading, // boolean として推論
      hasUser: user !== undefined, // boolean として推論
    };
  });

  return (
    <div>
      {userPosts.hasUser && (
        <div>
          <h2>{userPosts.user.name}</h2>
          {userPosts.isLoading ? (
            <div>投稿を読み込み中...</div>
          ) : (
            <div>{userPosts.posts.length} 件の投稿</div>
          )}
        </div>
      )}
    </div>
  );
};
typescript// TanStack Query: 複雑なデータ変換での型推論
interface RawApiData {
  users: Array<{
    id: string;
    firstName: string;
    lastName: string;
    posts: Array<{
      id: string;
      title: string;
      publishedAt: string;
    }>;
  }>;
}

const useTransformedData = () => {
  return useQuery({
    queryKey: ['complex-data'],
    queryFn: (): Promise<RawApiData> =>
      fetch('/api/complex-data').then((res) => res.json()),
    select: (data) => {
      // 複雑なデータ変換
      const transformedUsers = data.users.map((user) => ({
        id: user.id,
        fullName: `${user.firstName} ${user.lastName}`,
        postCount: user.posts.length,
        latestPost:
          user.posts.sort(
            (a, b) =>
              new Date(b.publishedAt).getTime() -
              new Date(a.publishedAt).getTime()
          )[0] || null,
      }));

      const statistics = {
        totalUsers: transformedUsers.length,
        totalPosts: transformedUsers.reduce(
          (sum, user) => sum + user.postCount,
          0
        ),
        activeUsers: transformedUsers.filter(
          (user) => user.postCount > 0
        ).length,
      };

      // 戻り値の型は自動推論される
      return {
        users: transformedUsers,
        stats: statistics,
      };
    },
  });
};

// 使用時にも正確な型推論
const ComplexDataView = () => {
  const { data, isLoading } = useTransformedData();

  if (isLoading) return <div>読み込み中...</div>;

  // data の型は select の戻り値として正確に推論される
  return (
    <div>
      <h2>統計情報</h2>
      <p>総ユーザー数: {data?.stats.totalUsers}</p>
      <p>総投稿数: {data?.stats.totalPosts}</p>
      <p>アクティブユーザー: {data?.stats.activeUsers}</p>

      <h2>ユーザー一覧</h2>
      {data?.users.map((user) => (
        <div key={user.id}>
          <h3>
            {user.fullName} ({user.postCount} 投稿)
          </h3>
          {user.latestPost && (
            <p>最新投稿: {user.latestPost.title}</p>
          )}
        </div>
      ))}
    </div>
  );
};

開発体験とコード補完

IDE サポートの質的比較

各ライブラリでの開発体験を、実際のコード補完例で比較してみましょう。

typescript// Zustand: 直感的なコード補完
const useShoppingCart = create<{
  items: CartItem[];
  total: number;
  addItem: (item: Product, quantity: number) => void;
  removeItem: (itemId: string) => void;
  updateQuantity: (
    itemId: string,
    quantity: number
  ) => void;
  clearCart: () => void;
}>((set, get) => ({
  items: [],
  total: 0,
  addItem: (product, quantity) => {
    set((state) => {
      const existingItem = state.items.find(
        (item) => item.product.id === product.id
      );

      if (existingItem) {
        // IDE が existingItem の型を正確に推論し、補完を提供
        return {
          items: state.items.map((item) =>
            item.product.id === product.id
              ? {
                  ...item,
                  quantity: item.quantity + quantity,
                }
              : item
          ),
        };
      }

      return {
        items: [
          ...state.items,
          { product, quantity, id: generateId() },
        ],
      };
    });
  },
  // 他のメソッドも同様に型安全...
}));

// 使用時の優れたコード補完体験
const ShoppingCartComponent = () => {
  const {
    items, // CartItem[] として補完
    total, // number として補完
    addItem, // (item: Product, quantity: number) => void として補完
    clearCart, // () => void として補完
  } = useShoppingCart();

  // state.items. を入力すると、配列メソッドが正確に補完される
  const itemCount = items.length;
  const hasItems = items.some((item) => item.quantity > 0);

  return <div>{/* コンポーネントの実装 */}</div>;
};
typescript// TanStack Query: 高度な型推論とコード補完
const useProductWithRelated = (productId: number) => {
  // メインの商品データ
  const productQuery = useQuery({
    queryKey: ['product', productId],
    queryFn: () => fetchProduct(productId),
    enabled: productId > 0, // 条件付きフェッチング
  });

  // 関連商品データ(メイン商品に依存)
  const relatedQuery = useQuery({
    queryKey: [
      'related-products',
      productQuery.data?.category,
    ],
    queryFn: () =>
      fetchRelatedProducts(productQuery.data!.category),
    enabled: !!productQuery.data?.category, // productQuery.data の存在確認
  });

  // レビューデータ
  const reviewsQuery = useQuery({
    queryKey: ['reviews', productId],
    queryFn: () => fetchReviews(productId),
    enabled: productId > 0,
  });

  // 統合されたデータと状態
  return {
    product: productQuery.data,
    relatedProducts: relatedQuery.data,
    reviews: reviewsQuery.data,
    isLoading:
      productQuery.isLoading || relatedQuery.isLoading,
    error:
      productQuery.error ||
      relatedQuery.error ||
      reviewsQuery.error,
    // すべてのプロパティが正確に型推論される
  };
};

// カスタムフックの使用
const ProductDetailPage = ({
  productId,
}: {
  productId: number;
}) => {
  const {
    product, // Product | undefined として補完
    relatedProducts, // Product[] | undefined として補完
    reviews, // Review[] | undefined として補完
    isLoading, // boolean として補完
    error, // Error | null として補完
  } = useProductWithRelated(productId);

  // IDE が各プロパティの型を正確に認識し、適切な補完を提供
  if (error) {
    return <div>エラー: {error.message}</div>; // error.message が補完される
  }

  return (
    <div>
      {product && (
        <div>
          <h1>{product.name}</h1>{' '}
          {/* product. で名前が補完される */}
          <p>価格: ¥{product.price.toLocaleString()}</p>
        </div>
      )}

      {relatedProducts && (
        <div>
          <h2>関連商品</h2>
          {relatedProducts.map(
            (
              item // item の型も Product として推論
            ) => (
              <div key={item.id}>{item.name}</div>
            )
          )}
        </div>
      )}
    </div>
  );
};
typescript// SWR: シンプルながら的確な型補完
interface PaginatedResponse<T> {
  data: T[]
  totalCount: number
  hasMore: boolean
  nextCursor?: string
}

const usePaginatedSWR = <T>(
  baseUrl: string,
  cursor?: string
) => {
  const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl

  const { data, error, mutate, isValidating } = useSWR<PaginatedResponse<T>>(
    url,
    typedFetcher<PaginatedResponse<T>>
  )

  // 型安全なページングロジック
  const loadMore = useCallback(() => {
    if (data?.hasMore && data.nextCursor) {
      // 次のページをプリフェッチ
      mutate(
        typedFetcher<PaginatedResponse<T>>(`${baseUrl}?cursor=${data.nextCursor}`),
        false
      )
    }
  }, [data, baseUrl, mutate])

  return {
    items: data?.data || [], // T[] として推論
    totalCount: data?.totalCount || 0, // number として推論
    hasMore: data?.hasMore || false, // boolean として推論
    isLoading: !data && !error, // boolean として推論
    isValidating, // boolean として推論
    error, // Error | undefined として推論
    loadMore, // () => void として推論
  }
}

// 使用例での優れたコード補完
const ProductPagination = () => {
  const [cursor, setCursor] = useState<string>()

  const {
    items,      // Product[] として補完
    hasMore,    // boolean として補完
    isLoading,  // boolean として補完
    loadMore    // () => void として補完
  } = usePaginatedSWR<Product>('/api/products', cursor)

  return (
    <div>
      {items.map(product => ( // product は Product 型として推論
        <div key={product.id}>
          <h3>{product.name}</h3> {/* product. で適切な補完 */}
          <p>¥{product.price.toLocaleString()}</p>
        </div>
      ))}

      {hasMore && (
        <button
          onClick={loadMore} // loadMore の型が正確に推論される
          disabled={isLoading}
        >
          {isLoading ? '読み込み中...' : 'さらに読み込む'}
        </button>
      )}
    </div>
  )
}

実運用での性能評価

バンドルサイズの比較

詳細なバンドルサイズ分析

実際のプロジェクトでのバンドルサイズへの影響を詳しく見てみましょう。

ライブラリ圧縮前サイズgzip 圧縮後機能密度追加依存関係
Zustand12.4KB2.9KB★★★★☆なし
TanStack Query123KB39KB★★★★★React 18+
SWR13.2KB4.2KB★★★☆☆なし
typescript// バンドルサイズの最適化例

// 1. Zustand - 最小構成
import { create } from 'zustand';
// 必要に応じてミドルウェアを個別インポート
import { persist } from 'zustand/middleware';
import { devtools } from 'zustand/middleware';

// 開発環境でのみ devtools を含める
const useOptimizedStore = create(
  process.env.NODE_ENV === 'development'
    ? devtools(
        persist(
          (set) => ({
            // ストア定義
          }),
          { name: 'app-store' }
        )
      )
    : persist(
        (set) => ({
          // ストア定義
        }),
        { name: 'app-store' }
      )
);

// 2. TanStack Query - 必要な機能のみインポート
import {
  useQuery,
  useMutation,
  useQueryClient,
} from '@tanstack/react-query';
// 開発ツールは条件付きインポート
const ReactQueryDevtools =
  process.env.NODE_ENV === 'development'
    ? require('@tanstack/react-query-devtools')
        .ReactQueryDevtools
    : () => null;

// 3. SWR - 必要な機能のみ使用
import useSWR from 'swr';
// 必要に応じて追加機能をインポート
import useSWRInfinite from 'swr/infinite';
import useSWRMutation from 'swr/mutation';

Tree Shaking の効果

typescript// 最適化前(非推奨)
import * as zustand from 'zustand';
import * as tanstackQuery from '@tanstack/react-query';
import * as swr from 'swr';

// 最適化後(推奨)
import { create } from 'zustand';
import {
  useQuery,
  useMutation,
} from '@tanstack/react-query';
import useSWR from 'swr';

// バンドルアナライザーでの確認方法
// 1. webpack-bundle-analyzer の使用
// 2. Next.js の場合
// next.config.js
module.exports = {
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.resolve.fallback = {
        fs: false,
        path: false,
      };
    }
    return config;
  },
  // バンドル分析を有効化
  experimental: {
    bundlePagesRouterDependencies: true,
  },
};

レンダリング最適化

各ライブラリでのレンダリング最適化比較

レンダリング頻度とパフォーマンスの実測値を比較してみましょう。

typescript// Zustand - 選択的購読によるレンダリング最適化
interface AppState {
  user: User | null;
  posts: Post[];
  ui: {
    isLoading: boolean;
    activeModal: string | null;
  };
}

// 非効率な購読(アンチパターン)
const InefficientComponent = () => {
  const state = useAppStore(); // 全状態を購読 → 頻繁な再レンダリング

  return <div>{state.user?.name}</div>;
};

// 効率的な購読(ベストプラクティス)
const EfficientComponent = () => {
  const userName = useAppStore((state) => state.user?.name); // 必要な部分のみ購読

  return <div>{userName}</div>;
};

// 複雑な選択での最適化
const OptimizedListComponent = () => {
  const filteredPosts = useAppStore(
    (state) =>
      state.posts.filter((post) => post.isPublished),
    (a, b) => a.length === b.length // カスタム比較関数
  );

  return (
    <ul>
      {filteredPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
};
typescript// TanStack Query - 自動最適化とカスタム最適化
const OptimizedQueryComponent = () => {
  const { data: posts } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    select: (data) => {
      // データ変換の結果のみが変更された場合に再レンダリング
      return data
        .filter((post) => post.status === 'published')
        .sort(
          (a, b) =>
            new Date(b.createdAt).getTime() -
            new Date(a.createdAt).getTime()
        );
    },
    // 構造化比較による最適化
    structuralSharing: true,
  });

  return (
    <ul>
      {posts?.map((post) => (
        <PostItem key={post.id} post={post} />
      ))}
    </ul>
  );
};

// メモ化を活用した最適化
const PostItem = memo(({ post }: { post: Post }) => {
  const { mutate: updatePost } = useMutation({
    mutationFn: (updatedPost: Partial<Post>) =>
      updatePostApi(post.id, updatedPost),
    onSuccess: () => {
      // 個別の投稿のみ無効化
      queryClient.invalidateQueries({
        queryKey: ['post', post.id],
      });
    },
  });

  return (
    <li>
      <h3>{post.title}</h3>
      <button
        onClick={() =>
          updatePost({ isLiked: !post.isLiked })
        }
      >
        {post.isLiked ? 'いいね済み' : 'いいね'}
      </button>
    </li>
  );
});
typescript// SWR - 効率的なデータフェッチングとレンダリング
const OptimizedSWRComponent = () => {
  const { data: posts } = useSWR('/api/posts', fetcher, {
    // 不必要な再検証を抑制
    revalidateOnFocus: false,
    revalidateOnReconnect: false,
    dedupingInterval: 5000,
  });

  // React.memo と組み合わせた最適化
  const memoizedPosts = useMemo(() => {
    return posts?.filter((post) => post.isVisible) || [];
  }, [posts]);

  return (
    <div>
      {memoizedPosts.map((post) => (
        <MemoizedPostItem key={post.id} post={post} />
      ))}
    </div>
  );
};

const MemoizedPostItem = memo(
  ({ post }: { post: Post }) => {
    const { trigger } = useSWRMutation(
      `/api/posts/${post.id}`,
      updatePostMutator
    );

    return (
      <li>
        <h3>{post.title}</h3>
        <button
          onClick={() =>
            trigger({ isBookmarked: !post.isBookmarked })
          }
        >
          {post.isBookmarked
            ? 'ブックマーク済み'
            : 'ブックマーク'}
        </button>
      </li>
    );
  },
  (prevProps, nextProps) => {
    // カスタム比較関数
    return (
      prevProps.post.id === nextProps.post.id &&
      prevProps.post.title === nextProps.post.title &&
      prevProps.post.isBookmarked ===
        nextProps.post.isBookmarked
    );
  }
);

レンダリングパフォーマンス測定

typescript// パフォーマンス測定のための実装例
const PerformanceTracker = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const renderCount = useRef(0);
  const startTime = useRef(Date.now());

  renderCount.current++;

  useEffect(() => {
    const endTime = Date.now();
    const renderTime = endTime - startTime.current;

    console.log(
      `レンダリング回数: ${renderCount.current}, 時間: ${renderTime}ms`
    );

    // 本番環境では分析サービスに送信
    if (process.env.NODE_ENV === 'production') {
      analytics.track('component_render', {
        renderCount: renderCount.current,
        renderTime,
        component: 'DataList',
      });
    }
  });

  return <>{children}</>;
};

// 使用例
const TrackedDataList = () => (
  <PerformanceTracker>
    <DataListComponent />
  </PerformanceTracker>
);

複雑な状態管理での比較

大規模アプリケーションでの実装比較

実際の管理画面レベルの複雑さで、各ライブラリがどのように動作するかを比較してみましょう。

typescript// 複雑な状態管理のシナリオ
interface DashboardState {
  // ユーザー関連
  currentUser: User | null;
  users: Record<string, User>;

  // データ関連
  products: Record<string, Product>;
  orders: Record<string, Order>;
  analytics: AnalyticsData | null;

  // UI状態
  filters: {
    dateRange: [Date, Date];
    category: string[];
    status: OrderStatus[];
  };

  // 非同期状態
  loadingStates: Record<string, boolean>;
  errors: Record<string, Error>;
}

Zustand での大規模状態管理

typescript// 状態のスライシング(推奨パターン)
const useUserSlice = create<{
  currentUser: User | null;
  users: Record<string, User>;
  setCurrentUser: (user: User) => void;
  addUser: (user: User) => void;
}>((set) => ({
  currentUser: null,
  users: {},
  setCurrentUser: (user) => set({ currentUser: user }),
  addUser: (user) =>
    set((state) => ({
      users: { ...state.users, [user.id]: user },
    })),
}));

const useProductSlice = create<{
  products: Record<string, Product>;
  addProduct: (product: Product) => void;
  updateProduct: (
    id: string,
    updates: Partial<Product>
  ) => void;
}>((set) => ({
  products: {},
  addProduct: (product) =>
    set((state) => ({
      products: {
        ...state.products,
        [product.id]: product,
      },
    })),
  updateProduct: (id, updates) =>
    set((state) => ({
      products: {
        ...state.products,
        [id]: { ...state.products[id], ...updates },
      },
    })),
}));

const useUISlice = create<{
  filters: DashboardState['filters'];
  updateFilters: (
    updates: Partial<DashboardState['filters']>
  ) => void;
}>((set) => ({
  filters: {
    dateRange: [new Date(), new Date()],
    category: [],
    status: [],
  },
  updateFilters: (updates) =>
    set((state) => ({
      filters: { ...state.filters, ...updates },
    })),
}));

// 複合コンポーネントでの使用
const ComplexDashboard = () => {
  const { currentUser } = useUserSlice();
  const { products, updateProduct } = useProductSlice();
  const { filters, updateFilters } = useUISlice();

  // 複数のスライスからデータを組み合わせ
  const filteredProducts = Object.values(products).filter(
    (product) => {
      return (
        filters.category.length === 0 ||
        filters.category.includes(product.category)
      );
    }
  );

  return (
    <div>
      <UserHeader user={currentUser} />
      <FilterControls
        filters={filters}
        onUpdateFilters={updateFilters}
      />
      <ProductGrid
        products={filteredProducts}
        onUpdateProduct={updateProduct}
      />
    </div>
  );
};

TanStack Query での大規模データ管理

typescript// クエリキーの体系的管理
const queryKeys = {
  users: {
    all: ['users'] as const,
    lists: () => [...queryKeys.users.all, 'list'] as const,
    list: (filters: UserFilters) =>
      [...queryKeys.users.lists(), filters] as const,
    details: () =>
      [...queryKeys.users.all, 'detail'] as const,
    detail: (id: string) =>
      [...queryKeys.users.details(), id] as const,
  },
  products: {
    all: ['products'] as const,
    lists: () =>
      [...queryKeys.products.all, 'list'] as const,
    list: (filters: ProductFilters) =>
      [...queryKeys.products.lists(), filters] as const,
    details: () =>
      [...queryKeys.products.all, 'detail'] as const,
    detail: (id: string) =>
      [...queryKeys.products.details(), id] as const,
  },
  analytics: {
    all: ['analytics'] as const,
    summary: (params: AnalyticsParams) =>
      [
        ...queryKeys.analytics.all,
        'summary',
        params,
      ] as const,
  },
} as const;

// 複雑なデータ関係の管理
const useDashboardData = (filters: DashboardFilters) => {
  // 基本データの取得
  const usersQuery = useQuery({
    queryKey: queryKeys.users.list(filters.userFilters),
    queryFn: () => fetchUsers(filters.userFilters),
    staleTime: 5 * 60 * 1000,
  });

  const productsQuery = useQuery({
    queryKey: queryKeys.products.list(
      filters.productFilters
    ),
    queryFn: () => fetchProducts(filters.productFilters),
    staleTime: 5 * 60 * 1000,
  });

  // 依存関係のあるデータ
  const analyticsQuery = useQuery({
    queryKey: queryKeys.analytics.summary({
      userIds: usersQuery.data?.map((u) => u.id) || [],
      productIds:
        productsQuery.data?.map((p) => p.id) || [],
      dateRange: filters.dateRange,
    }),
    queryFn: ({ queryKey }) =>
      fetchAnalytics(queryKey[2] as AnalyticsParams),
    enabled: !!(usersQuery.data && productsQuery.data),
    staleTime: 1 * 60 * 1000, // より頻繁に更新
  });

  // 集約データの計算
  const aggregatedData = useMemo(() => {
    if (
      !usersQuery.data ||
      !productsQuery.data ||
      !analyticsQuery.data
    ) {
      return null;
    }

    return {
      totalRevenue: analyticsQuery.data.revenue,
      topUsers: usersQuery.data
        .sort((a, b) => b.totalSpent - a.totalSpent)
        .slice(0, 5),
      topProducts: productsQuery.data
        .sort((a, b) => b.salesCount - a.salesCount)
        .slice(0, 5),
    };
  }, [
    usersQuery.data,
    productsQuery.data,
    analyticsQuery.data,
  ]);

  return {
    users: usersQuery.data,
    products: productsQuery.data,
    analytics: analyticsQuery.data,
    aggregatedData,
    isLoading:
      usersQuery.isLoading ||
      productsQuery.isLoading ||
      analyticsQuery.isLoading,
    error:
      usersQuery.error ||
      productsQuery.error ||
      analyticsQuery.error,
  };
};

// 複雑な更新操作の管理
const useComplexMutations = () => {
  const queryClient = useQueryClient();

  const updateProductMutation = useMutation({
    mutationFn: ({
      id,
      updates,
    }: {
      id: string;
      updates: Partial<Product>;
    }) => updateProduct(id, updates),
    onMutate: async ({ id, updates }) => {
      // 関連するクエリをキャンセル
      await queryClient.cancelQueries({
        queryKey: queryKeys.products.all,
      });

      // 楽観的更新
      const previousData = queryClient.getQueryData(
        queryKeys.products.detail(id)
      );
      queryClient.setQueryData(
        queryKeys.products.detail(id),
        (old: Product) => ({
          ...old,
          ...updates,
        })
      );

      return { previousData };
    },
    onSuccess: (updatedProduct) => {
      // 関連するクエリを無効化
      queryClient.invalidateQueries({
        queryKey: queryKeys.products.lists(),
      });
      queryClient.invalidateQueries({
        queryKey: queryKeys.analytics.all,
      });

      // 詳細データを直接更新
      queryClient.setQueryData(
        queryKeys.products.detail(updatedProduct.id),
        updatedProduct
      );
    },
    onError: (err, { id }, context) => {
      // ロールバック
      if (context?.previousData) {
        queryClient.setQueryData(
          queryKeys.products.detail(id),
          context.previousData
        );
      }
    },
  });

  return { updateProduct: updateProductMutation.mutate };
};

SWR での大規模データ管理

typescript// SWR での体系的なデータ管理
const useComplexSWRData = (filters: DashboardFilters) => {
  // 基本データの取得
  const { data: users } = useSWR(
    `/api/users?${new URLSearchParams(
      filters.userFilters
    ).toString()}`,
    fetcher,
    {
      revalidateOnFocus: false,
      dedupingInterval: 5000,
    }
  );

  const { data: products } = useSWR(
    `/api/products?${new URLSearchParams(
      filters.productFilters
    ).toString()}`,
    fetcher,
    {
      revalidateOnFocus: false,
      dedupingInterval: 5000,
    }
  );

  // 依存関係のあるデータ(条件付きフェッチング)
  const analyticsKey =
    users && products
      ? `/api/analytics?userIds=${users
          .map((u) => u.id)
          .join(',')}&productIds=${products
          .map((p) => p.id)
          .join(
            ','
          )}&startDate=${filters.dateRange[0].toISOString()}&endDate=${filters.dateRange[1].toISOString()}`
      : null;

  const { data: analytics } = useSWR(
    analyticsKey,
    fetcher,
    {
      refreshInterval: 60000, // 1分ごとに更新
      revalidateOnFocus: true, // 分析データはフォーカス時に更新
    }
  );

  // データの統合と変換
  const dashboardData = useMemo(() => {
    if (!users || !products || !analytics) {
      return null;
    }

    return {
      summary: {
        totalUsers: users.length,
        totalProducts: products.length,
        totalRevenue: analytics.totalRevenue,
      },
      trends: analytics.trends,
      topPerformers: {
        users: users
          .sort((a, b) => b.totalSpent - a.totalSpent)
          .slice(0, 10),
        products: products
          .sort((a, b) => b.salesCount - a.salesCount)
          .slice(0, 10),
      },
    };
  }, [users, products, analytics]);

  return {
    users,
    products,
    analytics,
    dashboardData,
    isLoading: !users || !products || !analytics,
  };
};

// 複雑な更新操作とキャッシュ管理
const useOptimisticUpdates = () => {
  const { mutate: globalMutate } = useSWRConfig();

  const updateProductWithOptimisticUpdate = useCallback(
    async (
      productId: string,
      updates: Partial<Product>
    ) => {
      const productKey = `/api/products/${productId}`;
      const productsListKey = '/api/products';

      try {
        // 楽観的更新
        globalMutate(
          productKey,
          (currentProduct: Product) => ({
            ...currentProduct,
            ...updates,
          }),
          false
        );

        // 商品リストも楽観的更新
        globalMutate(
          (key) =>
            typeof key === 'string' &&
            key.startsWith('/api/products'),
          (currentData: Product[]) =>
            currentData?.map((product) =>
              product.id === productId
                ? { ...product, ...updates }
                : product
            ),
          false
        );

        // 実際のAPI呼び出し
        const updatedProduct = await updateProduct(
          productId,
          updates
        );

        // 成功時は実際のデータで更新
        globalMutate(productKey, updatedProduct, false);

        // 関連するキャッシュを再検証
        globalMutate(
          (key) =>
            typeof key === 'string' &&
            key.startsWith('/api/analytics'),
          undefined,
          { revalidate: true }
        );

        return updatedProduct;
      } catch (error) {
        // エラー時は元のデータを再フェッチ
        globalMutate(productKey);
        globalMutate(productsListKey);
        throw error;
      }
    },
    [globalMutate]
  );

  return {
    updateProduct: updateProductWithOptimisticUpdate,
  };
};

まとめ

本記事では、Zustand、TanStack Query、SWR の 3 つのライブラリについて、キャッシュ戦略、再検証メカニズム、型安全性、実運用での性能の観点から詳細に比較検証しました。

各ライブラリの特徴まとめ

Zustand は、シンプルさと軽量性を重視する場合に最適です。バンドルサイズが小さく、学習コストが低いため、小〜中規模のプロジェクトや、複雑でないグローバル状態管理が必要な場合に威力を発揮します。TypeScript との親和性も高く、型安全性を保ちながら直感的な開発が可能です。

TanStack Query は、サーバー状態管理において最も包括的な機能を提供します。複雑なキャッシュ戦略、自動的な再検証、楽観的更新など、データドリブンなアプリケーションに必要な機能が充実しています。バンドルサイズは大きめですが、その分多機能で、大規模なプロジェクトでの運用実績も豊富です。

SWR は、シンプルながら効果的なデータフェッチングソリューションを提供します。Next.js との親和性が高く、Stale-While-Revalidate 戦略により優れたユーザー体験を実現できます。学習コストが低く、素早い開発が可能です。

プロジェクト選定の指針

  • 小〜中規模プロジェクト、シンプルな状態管理 → Zustand
  • 大規模プロジェクト、複雑なサーバー状態管理 → TanStack Query
  • Next.js プロジェクト、素早い開発 → SWR
  • バンドルサイズを重視 → Zustand または SWR
  • 高度なキャッシュ制御が必要 → TanStack Query

実際のプロジェクトでは、これらのライブラリを組み合わせて使用することも可能です。例えば、Zustand でクライアント状態を管理し、TanStack Query でサーバー状態を管理するハイブリッド構成も効果的な選択肢となります。

最終的な選択は、チームの技術レベル、プロジェクトの要件、将来の拡張性を総合的に考慮して決定することが重要です。

関連リンク