T-CREATOR

Zustandでの非同期処理とfetch連携パターン(パターン 6: キャッシュとデータ永続化)

Zustandでの非同期処理とfetch連携パターン(パターン 6: キャッシュとデータ永続化)

効率的な Web アプリケーションでは、API からのデータの取得を最適化し、ユーザー体験を向上させるためにキャッシュと永続化が重要です。この記事では、Zustand を使ったキャッシュ戦略とデータ永続化の実装方法について詳しく解説します。

パターン 6: キャッシュとデータ永続化

API リクエストを最適化し、オフライン対応を強化するには、データのキャッシュと永続化が重要です。Zustand でこれらを実装する方法を見ていきましょう。

キャッシュの基本実装

まず、シンプルなタイムベースのキャッシュを実装した例を見てみましょう:

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

interface CachedItem<T> {
  data: T;
  timestamp: number;
}

interface CachedDataStore<T> {
  // データと関連メタデータ
  cache: Record<string, CachedItem<T>>;
  cacheConfig: {
    ttl: number; // キャッシュの有効期間(ミリ秒)
  };

  // 状態
  isLoading: boolean;
  error: string | null;

  // アクション
  fetchData: (
    key: string,
    fetcher: () => Promise<T>
  ) => Promise<T>;
  invalidateCache: (key: string) => void;
  clearCache: () => void;
  setCacheTTL: (milliseconds: number) => void;
}

export const createCachedStore = <T>() =>
  create<CachedDataStore<T>>((set, get) => ({
    cache: {},
    cacheConfig: {
      ttl: 5 * 60 * 1000, // デフォルト: 5分
    },
    isLoading: false,
    error: null,

    fetchData: async (key, fetcher) => {
      // キャッシュチェック
      const cachedItem = get().cache[key];
      const now = Date.now();

      // 有効なキャッシュがある場合はそれを返す
      if (
        cachedItem &&
        now - cachedItem.timestamp < get().cacheConfig.ttl
      ) {
        return cachedItem.data;
      }

      // キャッシュがないか期限切れの場合は新しいデータを取得
      set({ isLoading: true, error: null });

      try {
        const data = await fetcher();

        // 取得したデータをキャッシュに保存
        set((state) => ({
          cache: {
            ...state.cache,
            [key]: { data, timestamp: now },
          },
          isLoading: false,
        }));

        return data;
      } catch (error) {
        set({
          error:
            error instanceof Error
              ? error.message
              : '取得エラー',
          isLoading: false,
        });
        throw error;
      }
    },

    invalidateCache: (key) => {
      set((state) => {
        const newCache = { ...state.cache };
        delete newCache[key];
        return { cache: newCache };
      });
    },

    clearCache: () => {
      set({ cache: {} });
    },

    setCacheTTL: (milliseconds) => {
      set((state) => ({
        cacheConfig: {
          ...state.cacheConfig,
          ttl: milliseconds,
        },
      }));
    },
  }));

// 使用例
export const useUserDataStore = createCachedStore<any>();

ユースケース: ユーザープロフィールのキャッシュ

上記のキャッシュストアを使用して、ユーザープロフィールデータをキャッシュする例:

typescript// src/components/UserProfile.tsx
import React, { useEffect } from 'react';
import { useUserDataStore } from '../stores/cachedDataStore';

interface UserProfileProps {
  userId: string;
}

export const UserProfile: React.FC<UserProfileProps> = ({
  userId,
}) => {
  const { fetchData, cache, isLoading, error } =
    useUserDataStore();

  // キャッシュから現在のユーザーデータを取得
  const cachedUser = cache[`user-${userId}`]?.data;

  useEffect(() => {
    // ユーザーデータをフェッチする関数
    const fetchUser = async () => {
      try {
        await fetchData(`user-${userId}`, async () => {
          const response = await fetch(
            `/api/users/${userId}`
          );
          if (!response.ok)
            throw new Error('Failed to fetch user');
          return response.json();
        });
      } catch (error) {
        console.error('Error fetching user:', error);
      }
    };

    fetchUser();
  }, [userId, fetchData]);

  if (isLoading && !cachedUser) {
    return <div>Loading user data...</div>;
  }

  if (error && !cachedUser) {
    return <div>Error: {error}</div>;
  }

  if (!cachedUser) {
    return <div>User not found</div>;
  }

  return (
    <div className='user-profile'>
      <h2>{cachedUser.name}</h2>
      <p>Email: {cachedUser.email}</p>
      {/* その他のユーザー情報 */}
    </div>
  );
};

高度なキャッシュ戦略

より複雑なアプリケーションでは、LRU(Least Recently Used)キャッシュのような高度な戦略が役立ちます:

typescript// src/utils/lruCache.ts
class LRUCache<K, V> {
  private capacity: number;
  private cache: Map<K, V>;

  constructor(capacity: number) {
    this.capacity = capacity;
    this.cache = new Map<K, V>();
  }

  get(key: K): V | undefined {
    if (!this.cache.has(key)) return undefined;

    // アクセスしたアイテムを最新に更新
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value!);
    return value;
  }

  put(key: K, value: V): void {
    // すでに存在する場合は削除
    if (this.cache.has(key)) {
      this.cache.delete(key);
    }
    // キャパシティオーバーの場合は最も古いアイテムを削除
    else if (this.cache.size >= this.capacity) {
      this.cache.delete(this.cache.keys().next().value);
    }
    // 新しいアイテムを追加
    this.cache.set(key, value);
  }

  has(key: K): boolean {
    return this.cache.has(key);
  }

  delete(key: K): boolean {
    return this.cache.delete(key);
  }

  clear(): void {
    this.cache.clear();
  }

  get size(): number {
    return this.cache.size;
  }
}

// LRUキャッシュを使ったZustandストア
export const createLRUCachedStore = <T>(capacity = 100) =>
  create<LRUCachedStore<T>>((set, get) => {
    const lruCache = new LRUCache<string, CachedItem<T>>(
      capacity
    );

    return {
      isLoading: false,
      error: null,

      fetchData: async (key, fetcher) => {
        // キャッシュチェック
        const cachedItem = lruCache.get(key);
        const now = Date.now();

        // 有効なキャッシュがある場合はそれを返す
        if (
          cachedItem &&
          now - cachedItem.timestamp < get().cacheConfig.ttl
        ) {
          return cachedItem.data;
        }

        // 新しいデータを取得
        set({ isLoading: true, error: null });

        try {
          const data = await fetcher();

          // LRUキャッシュに保存
          lruCache.put(key, { data, timestamp: now });
          set({ isLoading: false });

          return data;
        } catch (error) {
          set({
            error:
              error instanceof Error
                ? error.message
                : '取得エラー',
            isLoading: false,
          });
          throw error;
        }
      },

      invalidateCache: (key) => {
        lruCache.delete(key);
      },

      clearCache: () => {
        lruCache.clear();
      },

      getCacheSize: () => lruCache.size,

      cacheConfig: {
        ttl: 5 * 60 * 1000,
        capacity,
      },

      setCacheTTL: (milliseconds) => {
        set((state) => ({
          cacheConfig: {
            ...state.cacheConfig,
            ttl: milliseconds,
          },
        }));
      },
    };
  });

データの永続化

ブラウザを閉じても状態を保持するために、localStorage や IndexedDB を使用したデータ永続化を実装できます:

typescript// src/stores/persistentDataStore.ts
import { create } from 'zustand';
import {
  persist,
  createJSONStorage,
} from 'zustand/middleware';

interface User {
  id: string;
  name: string;
  email: string;
  preferences: Record<string, any>;
}

interface PersistentUserStore {
  user: User | null;
  token: string | null;
  isLoggedIn: boolean;

  // アクション
  setUser: (user: User | null) => void;
  setToken: (token: string | null) => void;
  login: (credentials: {
    email: string;
    password: string;
  }) => Promise<boolean>;
  logout: () => void;
  updateUserPreference: (key: string, value: any) => void;
}

export const usePersistentUserStore =
  create<PersistentUserStore>()(
    persist(
      (set, get) => ({
        user: null,
        token: null,
        isLoggedIn: false,

        setUser: (user) => {
          set({ user, isLoggedIn: !!user });
        },

        setToken: (token) => {
          set({ token });
        },

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

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

            const { user, token } = await response.json();

            set({
              user,
              token,
              isLoggedIn: true,
            });

            return true;
          } catch (error) {
            console.error('Login failed:', error);
            return false;
          }
        },

        logout: () => {
          set({
            user: null,
            token: null,
            isLoggedIn: false,
          });
        },

        updateUserPreference: (key, value) => {
          set((state) => {
            if (!state.user) return state;

            return {
              user: {
                ...state.user,
                preferences: {
                  ...state.user.preferences,
                  [key]: value,
                },
              },
            };
          });
        },
      }),
      {
        name: 'user-storage', // ストレージのキー
        storage: createJSONStorage(() => localStorage), // ストレージタイプ
        partialize: (state) => ({
          // トークンと最小限のユーザー情報のみを永続化
          token: state.token,
          user: state.user
            ? {
                id: state.user.id,
                name: state.user.name,
                preferences: state.user.preferences,
              }
            : null,
        }),
      }
    )
  );

IndexedDB を使用した大規模データの永続化

大規模なデータセットには、IndexedDB を使用した永続化が適しています:

typescript// src/utils/indexedDBStorage.ts
export const createIndexedDBStorage = (
  dbName: string,
  version = 1
) => {
  // データベース初期化
  const initDB = (): Promise<IDBDatabase> => {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(dbName, version);

      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest)
          .result;
        // 必要なオブジェクトストアを作成
        if (
          !db.objectStoreNames.contains('zustand-store')
        ) {
          db.createObjectStore('zustand-store');
        }
      };

      request.onerror = () => {
        reject(request.error);
      };

      request.onsuccess = () => {
        resolve(request.result);
      };
    });
  };

  // データの保存
  const setItem = async <T>(
    key: string,
    value: T
  ): Promise<void> => {
    const db = await initDB();
    return new Promise((resolve, reject) => {
      const transaction = db.transaction(
        'zustand-store',
        'readwrite'
      );
      const store =
        transaction.objectStore('zustand-store');
      const request = store.put(value, key);

      request.onerror = () => {
        reject(request.error);
      };

      transaction.oncomplete = () => {
        resolve();
      };
    });
  };

  // データの取得
  const getItem = async <T>(
    key: string
  ): Promise<T | null> => {
    const db = await initDB();
    return new Promise((resolve, reject) => {
      const transaction = db.transaction(
        'zustand-store',
        'readonly'
      );
      const store =
        transaction.objectStore('zustand-store');
      const request = store.get(key);

      request.onerror = () => {
        reject(request.error);
      };

      request.onsuccess = () => {
        resolve(request.result || null);
      };
    });
  };

  // データの削除
  const removeItem = async (key: string): Promise<void> => {
    const db = await initDB();
    return new Promise((resolve, reject) => {
      const transaction = db.transaction(
        'zustand-store',
        'readwrite'
      );
      const store =
        transaction.objectStore('zustand-store');
      const request = store.delete(key);

      request.onerror = () => {
        reject(request.error);
      };

      transaction.oncomplete = () => {
        resolve();
      };
    });
  };

  return {
    getItem,
    setItem,
    removeItem,
  };
};

// Zustandのpersistミドルウェアで使用できるストレージ
export const indexedDBStorage = (dbName: string) => {
  const storage = createIndexedDBStorage(dbName);

  return {
    getItem: async (name: string) => {
      const data = await storage.getItem(name);
      return JSON.stringify(data);
    },
    setItem: async (name: string, value: string) => {
      await storage.setItem(name, JSON.parse(value));
    },
    removeItem: async (name: string) => {
      await storage.removeItem(name);
    },
  };
};

この IndexedDB ストレージを使用した永続化ストアの例:

typescript// src/stores/largeDataStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { indexedDBStorage } from '../utils/indexedDBStorage';

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

interface LargeDataStore {
  products: Product[];
  lastUpdated: number | null;

  // アクション
  setProducts: (products: Product[]) => void;
  addProduct: (product: Product) => void;
  removeProduct: (id: string) => void;
  updateProduct: (
    id: string,
    updates: Partial<Product>
  ) => void;
}

export const useLargeDataStore = create<LargeDataStore>()(
  persist(
    (set) => ({
      products: [],
      lastUpdated: null,

      setProducts: (products) => {
        set({
          products,
          lastUpdated: Date.now(),
        });
      },

      addProduct: (product) => {
        set((state) => ({
          products: [...state.products, product],
          lastUpdated: Date.now(),
        }));
      },

      removeProduct: (id) => {
        set((state) => ({
          products: state.products.filter(
            (product) => product.id !== id
          ),
          lastUpdated: Date.now(),
        }));
      },

      updateProduct: (id, updates) => {
        set((state) => ({
          products: state.products.map((product) =>
            product.id === id
              ? { ...product, ...updates }
              : product
          ),
          lastUpdated: Date.now(),
        }));
      },
    }),
    {
      name: 'product-storage',
      storage: indexedDBStorage('large-data-app'),
    }
  )
);

キャッシュの自動更新と同期

データの鮮度を保ちながらも、パフォーマンスを最適化するために、バックグラウンドでの自動更新を実装できます:

typescript// src/stores/syncedDataStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface SyncConfig {
  interval: number; // ミリ秒単位の同期間隔
  enabled: boolean;
}

interface SyncedDataStore<T> {
  data: T | null;
  isLoading: boolean;
  error: string | null;
  lastSynced: number | null;
  syncConfig: SyncConfig;

  // アクション
  fetchData: () => Promise<void>;
  startAutoSync: () => void;
  stopAutoSync: () => void;
  setSyncInterval: (milliseconds: number) => void;
}

export const createSyncedStore = <T>(
  endpoint: string,
  storageKey: string
) =>
  create<SyncedDataStore<T>>()(
    persist(
      (set, get) => {
        let syncIntervalId: number | null = null;

        // データフェッチ関数
        const fetchData = async () => {
          set({ isLoading: true, error: null });

          try {
            const response = await fetch(endpoint);
            if (!response.ok) {
              throw new Error(
                `API error: ${response.status}`
              );
            }

            const data = await response.json();
            set({
              data,
              lastSynced: Date.now(),
              isLoading: false,
            });
          } catch (error) {
            set({
              error:
                error instanceof Error
                  ? error.message
                  : '同期エラー',
              isLoading: false,
            });
          }
        };

        // 自動同期の開始
        const startAutoSync = () => {
          if (syncIntervalId !== null) return;

          // すぐに同期を実行
          fetchData();

          // 定期的な同期を設定
          syncIntervalId = window.setInterval(
            fetchData,
            get().syncConfig.interval
          );

          set((state) => ({
            syncConfig: {
              ...state.syncConfig,
              enabled: true,
            },
          }));
        };

        // 自動同期の停止
        const stopAutoSync = () => {
          if (syncIntervalId !== null) {
            window.clearInterval(syncIntervalId);
            syncIntervalId = null;
          }

          set((state) => ({
            syncConfig: {
              ...state.syncConfig,
              enabled: false,
            },
          }));
        };

        // ページの可視性変更時の処理
        const handleVisibilityChange = () => {
          if (
            document.visibilityState === 'visible' &&
            get().syncConfig.enabled
          ) {
            // バックグラウンドから戻ってきたらすぐに同期
            fetchData();
          }
        };

        // 可視性イベントリスナーの設定
        if (typeof document !== 'undefined') {
          document.addEventListener(
            'visibilitychange',
            handleVisibilityChange
          );
        }

        return {
          data: null,
          isLoading: false,
          error: null,
          lastSynced: null,
          syncConfig: {
            interval: 5 * 60 * 1000, // デフォルト: 5分
            enabled: false,
          },

          fetchData,
          startAutoSync,
          stopAutoSync,

          setSyncInterval: (milliseconds) => {
            set((state) => ({
              syncConfig: {
                ...state.syncConfig,
                interval: milliseconds,
              },
            }));

            // すでに自動同期が有効なら、新しい間隔で再設定
            if (get().syncConfig.enabled) {
              stopAutoSync();
              startAutoSync();
            }
          },
        };
      },
      {
        name: storageKey,
        partialize: (state) => ({
          data: state.data,
          lastSynced: state.lastSynced,
          syncConfig: state.syncConfig,
        }),
      }
    )
  );

// 使用例
export const useProductsStore = createSyncedStore<any[]>(
  '/api/products',
  'synced-products'
);

キャッシュ対応コンポーネント

キャッシュとデータ永続化を活用したコンポーネントの例:

tsx// src/components/ProductList.tsx
import React, { useEffect } from 'react';
import { useProductsStore } from '../stores/syncedDataStore';

export const ProductList: React.FC = () => {
  const {
    data: products,
    isLoading,
    error,
    lastSynced,
    fetchData,
    startAutoSync,
    stopAutoSync,
  } = useProductsStore();

  // コンポーネントマウント時に自動同期を開始
  useEffect(() => {
    startAutoSync();

    // アンマウント時に自動同期を停止
    return () => {
      stopAutoSync();
    };
  }, [startAutoSync, stopAutoSync]);

  // データが一度も取得されていない場合、または長時間経過している場合は手動更新
  const handleRefresh = () => {
    fetchData();
  };

  // 最終同期時刻の表示用フォーマット
  const formatLastSynced = () => {
    if (!lastSynced) return 'Never';

    return new Date(lastSynced).toLocaleString();
  };

  return (
    <div className='product-list'>
      <div className='list-header'>
        <h2>Products</h2>
        <div className='sync-info'>
          <span>Last updated: {formatLastSynced()}</span>
          <button
            onClick={handleRefresh}
            disabled={isLoading}
          >
            {isLoading ? 'Refreshing...' : 'Refresh'}
          </button>
        </div>
      </div>

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

      {isLoading && !products && (
        <div className='loading'>Loading products...</div>
      )}

      <div className='products-grid'>
        {products?.map((product) => (
          <div key={product.id} className='product-card'>
            <img
              src={product.imageUrl}
              alt={product.name}
              className='product-image'
            />
            <h3>{product.name}</h3>
            <p className='price'>
              ${product.price.toFixed(2)}
            </p>
            <p className='description'>
              {product.description.substring(0, 100)}
              {product.description.length > 100
                ? '...'
                : ''}
            </p>
          </div>
        ))}
      </div>

      {products?.length === 0 && !isLoading && (
        <div className='no-products'>No products found</div>
      )}
    </div>
  );
};

ポイント

キャッシュとデータ永続化の実装における重要なポイント:

  1. キャッシュ戦略の選択: タイムベース、LRU、カスタム戦略など、アプリケーションに適したキャッシュ戦略を選択します。

  2. 保存するデータの選択: すべてのデータを永続化するのではなく、必要なデータのみを選択的に保存します。

  3. ストレージメカニズムの選択: localStorage(小規模)、IndexedDB(大規模)、SessionStorage(一時的)など、適切なストレージを選択します。

  4. 同期戦略: オフラインから復帰した際や定期的なバックグラウンド同期の戦略を決定します。

  5. エラー処理: キャッシュからのフォールバックや再試行ロジックを実装します。

キャッシュと永続化の比較

機能キャッシュ永続化
主な目的パフォーマンス向上データの保存
ライフタイム通常は短期間(セッション内)長期間(セッション間)
鮮度の重要性高い(古いデータは問題になる可能性あり)低い(多少古くても許容)
ストレージ容量一般的に制限ありより多くのデータを保存可能
ユースケースAPI レスポンス、計算結果ユーザー設定、認証状態

まとめ

この記事では、Zustand を使ったキャッシュ戦略とデータ永続化の実装方法について解説しました。効果的なキャッシュと永続化は、アプリケーションのパフォーマンスと信頼性を大幅に向上させる重要な要素です。

Zustand の柔軟性を活かすことで、アプリケーションの要件に合わせた最適なキャッシュ戦略を実装できます。シンプルなタイムベースのキャッシュから、LRU キャッシュのような高度な戦略まで、様々なアプローチが可能です。

また、Zustand のpersistミドルウェアを活用することで、データの永続化も容易に実装できます。localStorage を使った基本的な永続化から、IndexedDB を使った大規模データの保存まで、アプリケーションの規模に応じた選択肢があります。

適切なキャッシュと永続化を組み合わせることで、オフライン対応の強化、データ転送量の削減、ユーザー体験の向上など、多くのメリットを得ることができます。

関連リンク