T-CREATOR

Zustand でリストデータと詳細データを効率よく管理する方法

Zustand でリストデータと詳細データを効率よく管理する方法

React アプリケーションを開発していると、必ずと言っていいほど直面するのが「リストデータと詳細データの管理」です。ユーザー一覧から特定のユーザー詳細を表示する、商品リストから商品詳細ページに遷移する、タスク一覧から個別のタスクを編集する ── これらの場面で、どのように状態を管理すれば効率的で保守性の高いコードになるでしょうか。

従来の Redux や Context API を使った実装では、複雑なボイラープレートコードや不要な再レンダリングに悩まされることが少なくありませんでした。しかし、Zustand という軽量な状態管理ライブラリを使うことで、これらの課題をシンプルかつ効率的に解決できるのです。

この記事では、Zustand を使ったリストデータと詳細データの管理方法について、実際のコード例とエラーケースを交えながら詳しく解説していきます。きっと、あなたの開発効率を大きく向上させる気づきが得られるはずです。

背景

Zustand とは

Zustand は、React 用の軽量でシンプルな状態管理ライブラリです。Redux のような複雑な設定やボイラープレートコードが不要で、直感的な API を提供しています。

Zustand の特徴は以下の通りです:

  • シンプルな API: わずか数行でストアを作成できる
  • TypeScript 対応: 型安全性を保ちながら開発可能
  • バンドルサイズ: 約 2KB と非常に軽量
  • DevTools 対応: Redux DevTools との連携が可能
  • ミドルウェア: 必要に応じて機能を拡張可能

リスト・詳細データ管理の課題

リストデータと詳細データを管理する際、以下のような課題に直面することがあります:

1. データの重複 リストと詳細で同じデータを別々に管理すると、データの整合性が保てなくなります。

2. パフォーマンスの問題 不要な再レンダリングや、大量のデータを扱う際のメモリ使用量の増加が発生します。

3. キャッシュ戦略 詳細データの取得・更新時に、リストデータとの同期をどのように行うかが重要です。

4. エラーハンドリング ネットワークエラーやデータ取得失敗時の処理が複雑になりがちです。

課題

従来の状態管理の問題点

従来の Redux や Context API を使った実装では、以下のような問題が発生していました:

Redux の場合

typescript// 複雑なボイラープレートコード
const userSlice = createSlice({
  name: 'users',
  initialState: {
    list: [],
    selectedUser: null,
    loading: false,
    error: null,
  },
  reducers: {
    setUsers: (state, action) => {
      state.list = action.payload;
    },
    setSelectedUser: (state, action) => {
      state.selectedUser = action.payload;
    },
    // ... 多くのreducerが必要
  },
});

Context API の場合

typescript// プロバイダーの複雑な実装
const UserContext = createContext();

const UserProvider = ({ children }) => {
  const [users, setUsers] = useState([]);
  const [selectedUser, setSelectedUser] = useState(null);
  const [loading, setLoading] = useState(false);

  // 多くのuseEffectとハンドラーが必要
  useEffect(() => {
    // データ取得ロジック
  }, []);

  return (
    <UserContext.Provider
      value={{
        users,
        selectedUser,
        loading,
        setUsers,
        setSelectedUser,
        // ... 多くの関数
      }}
    >
      {children}
    </UserContext.Provider>
  );
};

パフォーマンスの課題

従来の実装では、以下のようなパフォーマンス問題が発生します:

1. 不要な再レンダリング

typescript// 問題のある実装例
const UserList = () => {
  const { users, selectedUser } = useContext(UserContext);

  // selectedUserが変更されても、UserList全体が再レンダリングされる
  return (
    <div>
      {users.map((user) => (
        <UserItem key={user.id} user={user} />
      ))}
    </div>
  );
};

2. メモリリーク

typescript// メモリリークを引き起こす可能性のあるコード
useEffect(() => {
  const fetchUsers = async () => {
    const response = await fetch('/api/users');
    const data = await response.json();
    setUsers(data); // コンポーネントがアンマウントされた後に実行される可能性
  };

  fetchUsers();
}, []); // クリーンアップ関数がない

データの整合性問題

データの整合性を保つために、以下のような複雑な処理が必要になります:

typescript// データ整合性を保つための複雑な処理
const updateUser = async (userId, userData) => {
  try {
    // API呼び出し
    const response = await fetch(`/api/users/${userId}`, {
      method: 'PUT',
      body: JSON.stringify(userData),
    });

    if (response.ok) {
      const updatedUser = await response.json();

      // リストデータの更新
      setUsers((prevUsers) =>
        prevUsers.map((user) =>
          user.id === userId ? updatedUser : user
        )
      );

      // 詳細データの更新
      if (selectedUser?.id === userId) {
        setSelectedUser(updatedUser);
      }

      // キャッシュの更新
      updateUserCache(userId, updatedUser);
    }
  } catch (error) {
    // エラーハンドリング
    console.error('Failed to update user:', error);
  }
};

解決策

Zustand の基本概念

Zustand では、シンプルな関数でストアを作成できます。基本的な概念を理解しましょう。

ストアの作成

typescriptimport { create } from 'zustand';

// 基本的なストア作成
const useUserStore = create((set, get) => ({
  // 状態
  users: [],
  selectedUser: null,
  loading: false,
  error: null,

  // アクション
  setUsers: (users) => set({ users }),
  setSelectedUser: (user) => set({ selectedUser: user }),
  setLoading: (loading) => set({ loading }),
  setError: (error) => set({ error }),
}));

コンポーネントでの使用

typescript// シンプルな使用例
const UserList = () => {
  const users = useUserStore((state) => state.users);
  const setSelectedUser = useUserStore(
    (state) => state.setSelectedUser
  );

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

ストア設計の原則

効率的なストア設計には、以下の原則が重要です:

1. 正規化されたデータ構造

typescript// 推奨されるデータ構造
interface UserStore {
  // 正規化されたデータ
  users: Record<string, User>;
  userIds: string[]; // 順序を保持

  // 選択状態
  selectedUserId: string | null;

  // UI状態
  loading: boolean;
  error: string | null;
}

2. セレクターの活用

typescript// 効率的なセレクター
const useUserStore = create<UserStore>((set, get) => ({
  users: {},
  userIds: [],
  selectedUserId: null,
  loading: false,
  error: null,

  // セレクター関数
  getUsers: () => {
    const { users, userIds } = get();
    return userIds.map((id) => users[id]);
  },

  getSelectedUser: () => {
    const { users, selectedUserId } = get();
    return selectedUserId ? users[selectedUserId] : null;
  },
}));

リスト・詳細データの関係性

リストデータと詳細データの関係を効率的に管理する方法を紹介します。

統合されたストア設計

typescriptinterface User {
  id: string;
  name: string;
  email: string;
  // 詳細データ
  profile?: {
    bio: string;
    avatar: string;
    preferences: Record<string, any>;
  };
}

interface UserStore {
  // データ
  users: Record<string, User>;
  userIds: string[];

  // 選択状態
  selectedUserId: string | null;

  // UI状態
  loading: boolean;
  error: string | null;

  // アクション
  fetchUsers: () => Promise<void>;
  fetchUserDetails: (userId: string) => Promise<void>;
  updateUser: (
    userId: string,
    updates: Partial<User>
  ) => Promise<void>;
  selectUser: (userId: string) => void;

  // セレクター
  getUsers: () => User[];
  getSelectedUser: () => User | null;
  getUserById: (id: string) => User | null;
}

具体例

シンプルな実装例

まずは、基本的なリスト・詳細データ管理の実装を見てみましょう。

ストアの実装

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

interface User {
  id: string;
  name: string;
  email: string;
  profile?: {
    bio: string;
    avatar: string;
  };
}

interface UserStore {
  // 状態
  users: Record<string, User>;
  userIds: string[];
  selectedUserId: string | null;
  loading: boolean;
  error: string | null;

  // アクション
  fetchUsers: () => Promise<void>;
  selectUser: (userId: string) => void;
  clearSelection: () => void;

  // セレクター
  getUsers: () => User[];
  getSelectedUser: () => User | null;
}

const useUserStore = create<UserStore>()(
  devtools(
    (set, get) => ({
      // 初期状態
      users: {},
      userIds: [],
      selectedUserId: null,
      loading: false,
      error: null,

      // ユーザー一覧の取得
      fetchUsers: async () => {
        set({ loading: true, error: null });

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

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

          const users: User[] = await response.json();

          // データを正規化
          const normalizedUsers = users.reduce(
            (acc, user) => {
              acc[user.id] = user;
              return acc;
            },
            {} as Record<string, User>
          );

          const userIds = users.map((user) => user.id);

          set({
            users: normalizedUsers,
            userIds,
            loading: false,
          });
        } catch (error) {
          set({
            error:
              error instanceof Error
                ? error.message
                : 'Unknown error',
            loading: false,
          });
        }
      },

      // ユーザー選択
      selectUser: (userId: string) => {
        set({ selectedUserId: userId });
      },

      // 選択クリア
      clearSelection: () => {
        set({ selectedUserId: null });
      },

      // セレクター
      getUsers: () => {
        const { users, userIds } = get();
        return userIds.map((id) => users[id]);
      },

      getSelectedUser: () => {
        const { users, selectedUserId } = get();
        return selectedUserId
          ? users[selectedUserId]
          : null;
      },
    }),
    { name: 'user-store' }
  )
);

export default useUserStore;

コンポーネントでの使用

typescriptimport React, { useEffect } from 'react';
import useUserStore from './stores/userStore';

// ユーザーリストコンポーネント
const UserList: React.FC = () => {
  const {
    getUsers,
    selectUser,
    fetchUsers,
    loading,
    error,
  } = useUserStore();

  const users = getUsers();

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

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

  return (
    <div className='user-list'>
      <h2>ユーザー一覧</h2>
      {users.map((user) => (
        <div
          key={user.id}
          className='user-item'
          onClick={() => selectUser(user.id)}
        >
          <h3>{user.name}</h3>
          <p>{user.email}</p>
        </div>
      ))}
    </div>
  );
};

// ユーザー詳細コンポーネント
const UserDetail: React.FC = () => {
  const { getSelectedUser, clearSelection } =
    useUserStore();
  const selectedUser = getSelectedUser();

  if (!selectedUser) {
    return (
      <div className='user-detail'>
        <p>ユーザーを選択してください</p>
      </div>
    );
  }

  return (
    <div className='user-detail'>
      <h2>ユーザー詳細</h2>
      <div>
        <h3>{selectedUser.name}</h3>
        <p>Email: {selectedUser.email}</p>
        {selectedUser.profile && (
          <div>
            <p>Bio: {selectedUser.profile.bio}</p>
            <img
              src={selectedUser.profile.avatar}
              alt='Avatar'
              style={{ width: 100, height: 100 }}
            />
          </div>
        )}
      </div>
      <button onClick={clearSelection}>選択をクリア</button>
    </div>
  );
};

最適化された実装例

次に、パフォーマンスを最適化した実装例を見てみましょう。

最適化されたストア

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

interface User {
  id: string;
  name: string;
  email: string;
  profile?: {
    bio: string;
    avatar: string;
  };
}

interface UserStore {
  // 状態
  users: Record<string, User>;
  userIds: string[];
  selectedUserId: string | null;
  loading: boolean;
  error: string | null;

  // アクション
  fetchUsers: () => Promise<void>;
  fetchUserDetails: (userId: string) => Promise<void>;
  updateUser: (
    userId: string,
    updates: Partial<User>
  ) => Promise<void>;
  selectUser: (userId: string) => void;
  clearSelection: () => void;

  // セレクター
  getUsers: () => User[];
  getSelectedUser: () => User | null;
  getUserById: (id: string) => User | null;
  isUserSelected: (id: string) => boolean;
}

const useUserStore = create<UserStore>()(
  subscribeWithSelector((set, get) => ({
    // 初期状態
    users: {},
    userIds: [],
    selectedUserId: null,
    loading: false,
    error: null,

    // ユーザー一覧の取得(キャッシュ対応)
    fetchUsers: async () => {
      const { users, userIds } = get();

      // 既にデータがある場合はスキップ
      if (userIds.length > 0) return;

      set({ loading: true, error: null });

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

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

        const usersData: User[] = await response.json();

        // データを正規化
        const normalizedUsers = usersData.reduce(
          (acc, user) => {
            acc[user.id] = user;
            return acc;
          },
          {} as Record<string, User>
        );

        const newUserIds = usersData.map((user) => user.id);

        set({
          users: { ...users, ...normalizedUsers },
          userIds: newUserIds,
          loading: false,
        });
      } catch (error) {
        set({
          error:
            error instanceof Error
              ? error.message
              : 'Unknown error',
          loading: false,
        });
      }
    },

    // ユーザー詳細の取得
    fetchUserDetails: async (userId: string) => {
      const { users } = get();
      const existingUser = users[userId];

      // 既に詳細データがある場合はスキップ
      if (existingUser?.profile) return;

      try {
        const response = await fetch(
          `/api/users/${userId}/details`
        );

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

        const userDetails = await response.json();

        set((state) => ({
          users: {
            ...state.users,
            [userId]: {
              ...state.users[userId],
              profile: userDetails.profile,
            },
          },
        }));
      } catch (error) {
        console.error(
          'Failed to fetch user details:',
          error
        );
      }
    },

    // ユーザー更新
    updateUser: async (
      userId: string,
      updates: Partial<User>
    ) => {
      try {
        const response = await fetch(
          `/api/users/${userId}`,
          {
            method: 'PUT',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify(updates),
          }
        );

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

        const updatedUser = await response.json();

        set((state) => ({
          users: {
            ...state.users,
            [userId]: updatedUser,
          },
        }));
      } catch (error) {
        set({
          error:
            error instanceof Error
              ? error.message
              : 'Update failed',
        });
      }
    },

    // ユーザー選択
    selectUser: (userId: string) => {
      set({ selectedUserId: userId });
      // 詳細データを自動取得
      get().fetchUserDetails(userId);
    },

    // 選択クリア
    clearSelection: () => {
      set({ selectedUserId: null });
    },

    // セレクター
    getUsers: () => {
      const { users, userIds } = get();
      return userIds.map((id) => users[id]).filter(Boolean);
    },

    getSelectedUser: () => {
      const { users, selectedUserId } = get();
      return selectedUserId ? users[selectedUserId] : null;
    },

    getUserById: (id: string) => {
      const { users } = get();
      return users[id] || null;
    },

    isUserSelected: (id: string) => {
      const { selectedUserId } = get();
      return selectedUserId === id;
    },
  }))
);

export default useUserStore;

最適化されたコンポーネント

typescriptimport React, { useEffect, memo } from 'react';
import useUserStore from './stores/userStore';

// メモ化されたユーザーアイテム
const UserItem = memo<{ userId: string }>(({ userId }) => {
  const user = useUserStore((state) =>
    state.getUserById(userId)
  );
  const isSelected = useUserStore((state) =>
    state.isUserSelected(userId)
  );
  const selectUser = useUserStore(
    (state) => state.selectUser
  );

  if (!user) return null;

  return (
    <div
      className={`user-item ${
        isSelected ? 'selected' : ''
      }`}
      onClick={() => selectUser(userId)}
    >
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
});

UserItem.displayName = 'UserItem';

// ユーザーリスト(最適化版)
const UserList: React.FC = () => {
  const { getUsers, fetchUsers, loading, error } =
    useUserStore();

  const users = getUsers();

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

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

  return (
    <div className='user-list'>
      <h2>ユーザー一覧</h2>
      {users.map((user) => (
        <UserItem key={user.id} userId={user.id} />
      ))}
    </div>
  );
};

// ユーザー詳細(最適化版)
const UserDetail: React.FC = () => {
  const selectedUser = useUserStore((state) =>
    state.getSelectedUser()
  );
  const clearSelection = useUserStore(
    (state) => state.clearSelection
  );
  const updateUser = useUserStore(
    (state) => state.updateUser
  );

  const handleUpdate = async (updates: Partial<User>) => {
    if (selectedUser) {
      await updateUser(selectedUser.id, updates);
    }
  };

  if (!selectedUser) {
    return (
      <div className='user-detail'>
        <p>ユーザーを選択してください</p>
      </div>
    );
  }

  return (
    <div className='user-detail'>
      <h2>ユーザー詳細</h2>
      <div>
        <h3>{selectedUser.name}</h3>
        <p>Email: {selectedUser.email}</p>
        {selectedUser.profile ? (
          <div>
            <p>Bio: {selectedUser.profile.bio}</p>
            <img
              src={selectedUser.profile.avatar}
              alt='Avatar'
              style={{ width: 100, height: 100 }}
            />
          </div>
        ) : (
          <p>詳細データを読み込み中...</p>
        )}
      </div>
      <button onClick={clearSelection}>選択をクリア</button>
    </div>
  );
};

パフォーマンス改善テクニック

Zustand でパフォーマンスを向上させるテクニックを紹介します。

1. セレクターの最適化

typescript// 効率的なセレクターの使用
const UserList = () => {
  // 必要な部分のみを購読
  const userIds = useUserStore((state) => state.userIds);
  const selectUser = useUserStore(
    (state) => state.selectUser
  );

  return (
    <div>
      {userIds.map((userId) => (
        <UserItem key={userId} userId={userId} />
      ))}
    </div>
  );
};

// 個別のユーザーアイテム
const UserItem = memo<{ userId: string }>(({ userId }) => {
  // 特定のユーザーのみを購読
  const user = useUserStore(
    useCallback((state) => state.users[userId], [userId])
  );

  const isSelected = useUserStore(
    useCallback(
      (state) => state.selectedUserId === userId,
      [userId]
    )
  );

  return (
    <div className={isSelected ? 'selected' : ''}>
      {user?.name}
    </div>
  );
});

2. バッチ更新の実装

typescript// バッチ更新によるパフォーマンス改善
const useUserStore = create<UserStore>()((set, get) => ({
  // ... 他の状態

  // バッチ更新
  batchUpdateUsers: (
    updates: Record<string, Partial<User>>
  ) => {
    set((state) => {
      const newUsers = { ...state.users };

      Object.entries(updates).forEach(
        ([userId, update]) => {
          if (newUsers[userId]) {
            newUsers[userId] = {
              ...newUsers[userId],
              ...update,
            };
          }
        }
      );

      return { users: newUsers };
    });
  },

  // 複数ユーザーの選択
  selectMultipleUsers: (userIds: string[]) => {
    set({ selectedUserIds: userIds });
  },
}));

3. エラーハンドリングの改善

typescript// 包括的なエラーハンドリング
const useUserStore = create<UserStore>()((set, get) => ({
  // ... 他の状態
  errors: {} as Record<string, string>,

  // エラー管理
  setError: (key: string, error: string) => {
    set((state) => ({
      errors: { ...state.errors, [key]: error },
    }));
  },

  clearError: (key: string) => {
    set((state) => {
      const newErrors = { ...state.errors };
      delete newErrors[key];
      return { errors: newErrors };
    });
  },

  // エラーハンドリング付きのデータ取得
  fetchUsers: async () => {
    const { setError, clearError } = get();

    set({ loading: true });
    clearError('fetchUsers');

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

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

      const users = await response.json();
      // ... データ処理
    } catch (error) {
      const errorMessage =
        error instanceof Error
          ? error.message
          : 'Unknown error';
      setError('fetchUsers', errorMessage);
    } finally {
      set({ loading: false });
    }
  },
}));

4. 実際のエラーケースと対処法

typescript// よくあるエラーとその対処法

// エラー1: 無限ループ
// ❌ 問題のあるコード
const UserList = () => {
  const fetchUsers = useUserStore(
    (state) => state.fetchUsers
  );

  useEffect(() => {
    fetchUsers(); // 毎回新しい関数が作成されるため無限ループ
  }, [fetchUsers]);
};

// ✅ 修正されたコード
const UserList = () => {
  const fetchUsers = useUserStore(
    (state) => state.fetchUsers
  );

  useEffect(() => {
    fetchUsers();
  }, []); // 依存配列を空にする
};

// エラー2: メモリリーク
// ❌ 問題のあるコード
const UserDetail = () => {
  const [userData, setUserData] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      const data = await fetch('/api/user');
      setUserData(data); // コンポーネントがアンマウントされた後に実行される可能性
    };

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

// ✅ 修正されたコード
const UserDetail = () => {
  const [userData, setUserData] = useState(null);

  useEffect(() => {
    let isMounted = true;

    const fetchData = async () => {
      try {
        const data = await fetch('/api/user');
        if (isMounted) {
          setUserData(data);
        }
      } catch (error) {
        if (isMounted) {
          console.error(
            'Failed to fetch user data:',
            error
          );
        }
      }
    };

    fetchData();

    return () => {
      isMounted = false;
    };
  }, []);
};

// エラー3: 型エラー
// ❌ 問題のあるコード
const useUserStore = create((set) => ({
  users: [],
  addUser: (user) =>
    set((state) => ({
      users: [...state.users, user],
    })),
}));

// ✅ 修正されたコード
interface User {
  id: string;
  name: string;
  email: string;
}

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

const useUserStore = create<UserStore>((set) => ({
  users: [],
  addUser: (user: User) =>
    set((state) => ({
      users: [...state.users, user],
    })),
}));

まとめ

Zustand を使ったリストデータと詳細データの管理について、実践的なアプローチを紹介してきました。

重要なポイント

  1. 正規化されたデータ構造: データの重複を避け、効率的な更新を実現
  2. セレクターの活用: 必要な部分のみを購読し、不要な再レンダリングを防止
  3. エラーハンドリング: 包括的なエラー管理でユーザー体験を向上
  4. パフォーマンス最適化: メモ化とバッチ更新でスムーズな動作を実現

実際の開発での活用

この記事で紹介したパターンは、ユーザー管理システム、商品カタログ、タスク管理アプリなど、様々な場面で活用できます。特に、大量のデータを扱うアプリケーションでは、Zustand の軽量さとシンプルさが大きなメリットとなります。

次のステップ

Zustand の基本を理解したら、以下のような発展的な機能にも挑戦してみてください:

  • ミドルウェアの活用(persist、immer 等)
  • 複数ストアの連携
  • リアルタイム更新の実装
  • テスト戦略の構築

Zustand を使うことで、複雑な状態管理をシンプルに、そして効率的に実現できることを実感していただけたでしょうか。きっと、あなたの開発効率とコードの保守性が大きく向上するはずです。

関連リンク