T-CREATOR

Zustand で一括データ取得・一部更新を効率化する設計法

Zustand で一括データ取得・一部更新を効率化する設計法

React アプリケーションでの状態管理は、アプリケーションの規模が大きくなるにつれて複雑になります。特に大量のデータを扱う場合、効率的な状態管理が重要な課題となります。

Zustand は軽量で使いやすい状態管理ライブラリとして注目を集めていますが、大規模なデータを扱う際には適切な設計パターンを理解する必要があります。本記事では、Zustand を使用した一括データ取得と部分更新を効率化する具体的な設計法をご紹介いたします。

背景

Zustand を使用した状態管理の基本概念

Zustand は、Redux のような複雑な設定を必要とせず、シンプルな API で状態管理を実現できるライブラリです。基本的な使い方から確認してみましょう。

typescriptimport { create } from 'zustand';

interface UserStore {
  users: User[];
  setUsers: (users: User[]) => void;
}

const useUserStore = create<UserStore>((set) => ({
  users: [],
  setUsers: (users) => set({ users }),
}));

このような基本的な実装では、少数のデータを扱う場合には十分ですが、大量データを扱う場合にはいくつかの課題が生じてきます。

大量データを扱うアプリケーションでの課題

現代の Web アプリケーションでは、数千から数万件のデータを一度に扱うことが珍しくありません。例えば、以下のような場面です。

#シーンデータ件数課題
1ユーザー管理システム10,000+メモリ使用量の増大
2商品管理画面50,000+描画パフォーマンスの低下
3ログ分析ダッシュボード100,000+データ更新時の遅延

これらの課題は、適切な設計パターンを適用することで解決できます。

パフォーマンス最適化の重要性

大量データを扱う際のパフォーマンス最適化は、単純にユーザー体験を向上させるだけでなく、以下のような効果をもたらします。

サーバーリソースの削減により、運用コストを大幅に抑えることができます。また、レスポンス時間の短縮は直接的にコンバージョン率の向上に繋がることが多くの調査で明らかになっています。

さらに、メモリ使用量の最適化により、モバイルデバイスでもスムーズな動作を実現できるため、より広いユーザー層にアプローチすることが可能になります。

課題

一括データ取得時のメモリ使用量問題

大量のデータを一括で取得する際、最も顕著に現れる問題がメモリ使用量の増大です。従来のアプローチでは以下のような実装が一般的でした。

typescript// 問題のある実装例
const useLargeDataStore = create<LargeDataStore>((set) => ({
  data: [],
  fetchAllData: async () => {
    const response = await fetch('/api/data');
    const data = await response.json(); // 全データを一度にメモリに展開
    set({ data });
  },
}));

この実装では、10 万件のデータを取得する際に数百 MB のメモリを瞬時に消費してしまう可能性があります。特にモバイルデバイスでは、メモリ不足によりアプリケーションがクラッシュする恐れもあります。

部分更新時の不要な再レンダリング

Zustand の標準的な実装では、ストアの一部が変更された際に、そのストアを参照しているすべてのコンポーネントが再レンダリングされます。

typescript// 不要な再レンダリングが発生する例
const UserList = () => {
  const users = useUserStore((state) => state.users); // 全ユーザーを監視

  return (
    <div>
      {users.map((user) => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
};

1 つのユーザー情報が更新された場合でも、上記の実装ではUserListコンポーネント全体が再レンダリングされ、パフォーマンスが大幅に低下してしまいます。

複雑なデータ構造での更新処理の非効率性

ネストした複雑なデータ構造を扱う場合、immutable な更新を実現するために多くのスプレッド演算子を使用する必要があります。

typescript// 非効率な更新処理の例
const updateUserProfile = (
  userId: string,
  profileData: Partial<Profile>
) =>
  set((state) => ({
    users: state.users.map((user) =>
      user.id === userId
        ? {
            ...user,
            profile: {
              ...user.profile,
              ...profileData,
              address: {
                ...user.profile.address,
                // さらにネストが深くなる...
              },
            },
          }
        : user
    ),
  }));

このような実装は可読性が悪いだけでなく、計算コストも高くなってしまいます。

ネットワーク通信の最適化不足

従来のアプローチでは、データの取得や更新に関してネットワーク通信の最適化が十分に考慮されていません。

必要のないデータまで取得してしまったり、同じデータを重複して取得してしまったりする問題が頻繁に発生します。また、更新処理においても、変更された部分のみを送信するのではなく、オブジェクト全体を送信してしまうケースが多く見られます。

解決策

効率的なストア設計パターン

大量データを効率的に管理するためには、データをカテゴリごとに分割して管理する設計パターンが有効です。

typescript// 効率的なストア分割パターン
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

// ユーザー基本情報ストア
interface UserBasicStore {
  users: Map<string, UserBasic>;
  loadUsers: (users: UserBasic[]) => void;
  updateUser: (
    id: string,
    updates: Partial<UserBasic>
  ) => void;
}

const useUserBasicStore = create<UserBasicStore>()(
  subscribeWithSelector((set, get) => ({
    users: new Map(),

    loadUsers: (users) => {
      const userMap = new Map();
      users.forEach((user) => userMap.set(user.id, user));
      set({ users: userMap });
    },

    updateUser: (id, updates) => {
      const { users } = get();
      const updatedUsers = new Map(users);
      const existingUser = updatedUsers.get(id);

      if (existingUser) {
        updatedUsers.set(id, {
          ...existingUser,
          ...updates,
        });
        set({ users: updatedUsers });
      }
    },
  }))
);

Map オブジェクトを使用することで、O(1)の時間計算量でデータにアクセスできるため、大量データでも高速な検索・更新が可能になります。

セレクター関数を活用した最適化

必要なデータのみを取得するセレクター関数を実装することで、不要な再レンダリングを防げます。

typescript// 最適化されたセレクター関数
const useUserById = (userId: string) =>
  useUserBasicStore(
    (state) => state.users.get(userId),
    (a, b) =>
      a?.id === b?.id && a?.updatedAt === b?.updatedAt
  );

const useUsersByStatus = (status: UserStatus) =>
  useUserBasicStore(
    (state) =>
      Array.from(state.users.values()).filter(
        (user) => user.status === status
      ),
    (a, b) => {
      if (a.length !== b.length) return false;
      return a.every(
        (user, index) =>
          user.id === b[index]?.id &&
          user.updatedAt === b[index]?.updatedAt
      );
    }
  );

カスタムの等価性チェック関数を提供することで、実際にデータが変更された場合のみコンポーネントが再レンダリングされるようになります。

immer を使用した immutable 更新

複雑なデータ構造の更新には、immer ライブラリを活用することで、可読性と効率性を両立できます。

typescriptimport { produce } from 'immer';

// immerを使用した更新パターン
interface UserDetailStore {
  userDetails: Map<string, UserDetail>;
  updateUserProfile: (
    userId: string,
    profileUpdates: Partial<Profile>
  ) => void;
}

const useUserDetailStore = create<UserDetailStore>(
  (set) => ({
    userDetails: new Map(),

    updateUserProfile: (userId, profileUpdates) =>
      set(
        produce((state) => {
          const userDetail = state.userDetails.get(userId);
          if (userDetail) {
            Object.assign(
              userDetail.profile,
              profileUpdates
            );
          }
        })
      ),
  })
);

immer を使用することで、mutable な書き方でコードを記述しながら、内部的には immutable な更新が実行されます。

非同期処理の最適化手法

データ取得時の効率化には、以下のような手法が有効です。

typescript// 非同期処理最適化パターン
interface DataFetchStore {
  cache: Map<string, { data: any; timestamp: number }>;
  loading: Set<string>;
  fetchWithCache: <T>(
    key: string,
    fetcher: () => Promise<T>,
    ttl?: number
  ) => Promise<T>;
}

const useDataFetchStore = create<DataFetchStore>(
  (set, get) => ({
    cache: new Map(),
    loading: new Set(),

    fetchWithCache: async (key, fetcher, ttl = 300000) => {
      // 5分のTTL
      const { cache, loading } = get();

      // 既にロード中の場合は重複リクエストを防ぐ
      if (loading.has(key)) {
        return new Promise((resolve) => {
          const checkCache = () => {
            const cached = cache.get(key);
            if (cached) {
              resolve(cached.data);
            } else {
              setTimeout(checkCache, 100);
            }
          };
          checkCache();
        });
      }

      // キャッシュチェック
      const cached = cache.get(key);
      if (cached && Date.now() - cached.timestamp < ttl) {
        return cached.data;
      }

      // データ取得処理
      set((state) => ({
        loading: new Set(state.loading).add(key),
      }));

      try {
        const data = await fetcher();
        const newCache = new Map(cache);
        newCache.set(key, { data, timestamp: Date.now() });

        set((state) => ({
          cache: newCache,
          loading: new Set(
            Array.from(state.loading).filter(
              (k) => k !== key
            )
          ),
        }));

        return data;
      } catch (error) {
        set((state) => ({
          loading: new Set(
            Array.from(state.loading).filter(
              (k) => k !== key
            )
          ),
        }));
        throw error;
      }
    },
  })
);

このパターンにより、重複リクエストの防止、キャッシュの活用、エラーハンドリングを包括的に実現できます。

具体例

大量ユーザーデータの管理システム

10,000 人以上のユーザーを管理するシステムでの実装例をご紹介します。

まず、データ構造を効率的に設計します:

typescript// ユーザーデータ型定義
interface UserBasic {
  id: string;
  name: string;
  email: string;
  status: 'active' | 'inactive' | 'pending';
  lastLoginAt: Date;
  createdAt: Date;
  updatedAt: Date;
}

interface UserDetail {
  id: string;
  profile: UserProfile;
  permissions: Permission[];
  activityLog: ActivityLog[];
}

基本ストアと詳細ストアを分離することで、メモリ効率と検索性能を向上させます:

typescript// 基本情報ストア(常時メモリに保持)
const useUserBasicStore = create<UserBasicStore>()(
  subscribeWithSelector((set, get) => ({
    users: new Map(),
    filteredUserIds: [],
    searchQuery: '',
    statusFilter: 'all',

    loadUsers: async (page = 1, limit = 1000) => {
      const response = await fetch(
        `/api/users?page=${page}&limit=${limit}`
      );
      const users: UserBasic[] = await response.json();

      set((state) => {
        const newUsers = new Map(state.users);
        users.forEach((user) =>
          newUsers.set(user.id, user)
        );
        return { users: newUsers };
      });
    },

    setSearchQuery: (query) => {
      set({ searchQuery: query });
      get().applyFilters();
    },

    applyFilters: () => {
      const { users, searchQuery, statusFilter } = get();

      const filtered = Array.from(users.values())
        .filter((user) => {
          const matchesSearch =
            !searchQuery ||
            user.name
              .toLowerCase()
              .includes(searchQuery.toLowerCase()) ||
            user.email
              .toLowerCase()
              .includes(searchQuery.toLowerCase());

          const matchesStatus =
            statusFilter === 'all' ||
            user.status === statusFilter;

          return matchesSearch && matchesStatus;
        })
        .map((user) => user.id);

      set({ filteredUserIds: filtered });
    },
  }))
);

詳細情報は必要に応じてオンデマンドで取得します:

typescript// 詳細情報ストア(オンデマンド取得)
const useUserDetailStore = create<UserDetailStore>(
  (set, get) => ({
    userDetails: new Map(),

    fetchUserDetail: async (userId: string) => {
      const { userDetails } = get();

      if (userDetails.has(userId)) {
        return userDetails.get(userId)!;
      }

      const response = await fetch(
        `/api/users/${userId}/detail`
      );
      const detail: UserDetail = await response.json();

      set((state) => ({
        userDetails: new Map(state.userDetails).set(
          userId,
          detail
        ),
      }));

      return detail;
    },
  })
);

リアルタイム更新が必要な管理画面

WebSocket を使用したリアルタイム更新システムでの実装例です。

typescript// リアルタイム更新対応ストア
interface RealtimeStore {
  connectionStatus:
    | 'connected'
    | 'disconnected'
    | 'connecting';
  subscribe: (
    eventType: string,
    callback: (data: any) => void
  ) => () => void;
  initConnection: () => void;
}

const useRealtimeStore = create<RealtimeStore>(
  (set, get) => {
    let ws: WebSocket | null = null;
    const eventHandlers = new Map<
      string,
      Set<(data: any) => void>
    >();

    return {
      connectionStatus: 'disconnected',

      initConnection: () => {
        if (ws?.readyState === WebSocket.OPEN) return;

        set({ connectionStatus: 'connecting' });
        ws = new WebSocket(process.env.NEXT_PUBLIC_WS_URL!);

        ws.onopen = () => {
          set({ connectionStatus: 'connected' });
        };

        ws.onmessage = (event) => {
          const { type, data } = JSON.parse(event.data);
          const handlers = eventHandlers.get(type);
          if (handlers) {
            handlers.forEach((handler) => handler(data));
          }
        };

        ws.onclose = () => {
          set({ connectionStatus: 'disconnected' });
          setTimeout(() => get().initConnection(), 3000); // 再接続
        };
      },

      subscribe: (eventType, callback) => {
        if (!eventHandlers.has(eventType)) {
          eventHandlers.set(eventType, new Set());
        }
        eventHandlers.get(eventType)!.add(callback);

        return () => {
          const handlers = eventHandlers.get(eventType);
          if (handlers) {
            handlers.delete(callback);
            if (handlers.size === 0) {
              eventHandlers.delete(eventType);
            }
          }
        };
      },
    };
  }
);

リアルタイム更新をユーザーストアと連携させる実装:

typescript// リアルタイム更新の統合
export const useRealtimeUserUpdates = () => {
  const updateUser = useUserBasicStore(
    (state) => state.updateUser
  );
  const subscribe = useRealtimeStore(
    (state) => state.subscribe
  );

  useEffect(() => {
    const unsubscribe = subscribe(
      'user_updated',
      (userData: UserBasic) => {
        updateUser(userData.id, userData);
      }
    );

    return unsubscribe;
  }, [updateUser, subscribe]);
};

ページネーション対応のデータ管理

大量データを効率的に表示するためのページネーション実装例です。

typescript// ページネーション対応ストア
interface PaginatedStore<T> {
  pages: Map<number, T[]>;
  totalCount: number;
  pageSize: number;
  currentPage: number;
  loading: boolean;
  loadPage: (page: number) => Promise<void>;
  getPageData: (page: number) => T[] | undefined;
}

const createPaginatedStore = <T>(
  fetcher: (
    page: number,
    pageSize: number
  ) => Promise<{ data: T[]; total: number }>
) =>
  create<PaginatedStore<T>>((set, get) => ({
    pages: new Map(),
    totalCount: 0,
    pageSize: 50,
    currentPage: 1,
    loading: false,

    loadPage: async (page) => {
      const { pages, pageSize } = get();

      if (pages.has(page)) return;

      set({ loading: true });

      try {
        const result = await fetcher(page, pageSize);

        set((state) => ({
          pages: new Map(state.pages).set(
            page,
            result.data
          ),
          totalCount: result.total,
          currentPage: page,
          loading: false,
        }));
      } catch (error) {
        set({ loading: false });
        throw error;
      }
    },

    getPageData: (page) => {
      return get().pages.get(page);
    },
  }));

仮想スクロール対応の実装:

typescript// 仮想スクロール対応フック
export const useVirtualizedData = <T>(
  store: PaginatedStore<T>,
  containerHeight: number,
  itemHeight: number
) => {
  const [startIndex, setStartIndex] = useState(0);
  const [scrollTop, setScrollTop] = useState(0);

  const visibleCount = Math.ceil(
    containerHeight / itemHeight
  );
  const endIndex = startIndex + visibleCount;

  const currentPage =
    Math.floor(startIndex / store.pageSize) + 1;
  const nextPage =
    Math.floor(endIndex / store.pageSize) + 1;

  useEffect(() => {
    store.loadPage(currentPage);
    if (nextPage !== currentPage) {
      store.loadPage(nextPage);
    }
  }, [currentPage, nextPage]);

  const visibleItems: T[] = [];
  for (let i = startIndex; i < endIndex; i++) {
    const page = Math.floor(i / store.pageSize) + 1;
    const indexInPage = i % store.pageSize;
    const pageData = store.getPageData(page);

    if (pageData?.[indexInPage]) {
      visibleItems.push(pageData[indexInPage]);
    }
  }

  return {
    visibleItems,
    totalCount: store.totalCount,
    scrollTop,
    onScroll: (newScrollTop: number) => {
      setScrollTop(newScrollTop);
      setStartIndex(Math.floor(newScrollTop / itemHeight));
    },
  };
};

まとめ

Zustand を使用した一括データ取得・一部更新の効率化には、適切な設計パターンの選択が重要です。

本記事でご紹介した手法を要約すると以下のようになります。まず、データをカテゴリごとに分割してストアを設計することで、メモリ使用量の削減と検索性能の向上を実現できます。Map オブジェクトの活用により、O(1)の時間計算量でのデータアクセスが可能になります。

セレクター関数の最適化により、必要なデータのみを監視して不要な再レンダリングを防げます。カスタムの等価性チェック関数を実装することで、より精密な更新検知が可能になります。

複雑なデータ構造の更新には immer ライブラリを活用することで、可読性と効率性を両立できます。非同期処理の最適化では、キャッシュ機能や重複リクエストの防止により、ネットワーク通信を大幅に削減できます。

これらの手法を適切に組み合わせることで、大規模なデータを扱うアプリケーションでも、快適なユーザー体験を提供できるでしょう。実装の際は、アプリケーションの要件に合わせて段階的に最適化を進めることをお勧めします。

関連リンク