T-CREATOR

状態の構造化:Zustand で Entity 管理・正規化設計を試してみる

状態の構造化:Zustand で Entity 管理・正規化設計を試してみる

React アプリケーションの開発において、状態管理は避けて通れない重要な要素です。特に、複雑なデータ構造を扱うアプリケーションでは、適切な状態管理戦略が成功の鍵を握ります。

本記事では、軽量で直感的な状態管理ライブラリ「Zustand」を使用して、エンティティ管理と正規化設計を実装する方法をご紹介します。実際のコード例とともに、よくある問題とその解決策を詳しく解説していきますので、ぜひ最後までお読みください。

背景

従来の状態管理の課題

現代の Web アプリケーション開発において、状態管理は日々複雑化しています。特に Redux や Context API を使用した従来のアプローチでは、以下のような課題に直面することが多いでしょう。

typescript// 従来のContext APIを使った状態管理の例
const AppContext = createContext({
  users: [],
  posts: [],
  comments: [],
  loading: false,
  error: null,
});

// 複雑な状態更新ロジック
const reducer = (state, action) => {
  switch (action.type) {
    case 'UPDATE_USER':
      return {
        ...state,
        users: state.users.map((user) =>
          user.id === action.payload.id
            ? { ...user, ...action.payload }
            : user
        ),
      };
    // さらに多くのケース...
  }
};

このような実装では、状態の更新が複雑になりがちで、メンテナンスが困難になってしまいます。また、TypeScript の型安全性を保つのも一苦労です。

正規化設計の重要性

データベース設計において正規化が重要であるように、フロントエンドの状態管理においても正規化は極めて重要です。正規化されていない状態は、データの重複や整合性の問題を引き起こします。

項目非正規化状態正規化状態
データの重複同じデータが複数箇所に存在一意の場所にのみ存在
更新の複雑さ複数箇所を同時に更新単一箇所の更新のみ
整合性不整合が発生しやすい整合性が保たれる
パフォーマンス無駄な再レンダリング効率的な更新

Zustand での Entity 管理の必要性

Zustand は、その軽量性と直感的な API で人気を集めています。しかし、単純な使用では、大規模なアプリケーションにおいて状態管理の複雑さを解決できません。

ここで Entity 管理パターンを導入することで、Zustand の持つ利点を最大化しながら、スケーラブルな状態管理を実現できるのです。

課題

非正規化状態の問題点

実際のプロジェクトで遭遇する、非正規化状態の典型的な問題を見てみましょう。

typescript// 問題のあるストア設計例
interface BadStore {
  posts: Array<{
    id: string;
    title: string;
    content: string;
    author: {
      id: string;
      name: string;
      email: string;
    };
    comments: Array<{
      id: string;
      content: string;
      author: {
        id: string;
        name: string;
        email: string;
      };
    }>;
  }>;
}

このような構造では、同じユーザーがデータ構造の複数箇所に重複して存在することになります。ユーザー情報が更新された際、すべての関連データを更新する必要があり、バグの温床となってしまいます。

データの重複と整合性の問題

実際にこのような問題が発生した際のエラー例を見てみましょう。

typescript// 実際に発生するエラーの例
const updateUserInfo = (
  userId: string,
  newInfo: Partial<User>
) => {
  // 投稿データのユーザー情報を更新
  const updatedPosts = posts.map((post) =>
    post.author.id === userId
      ? { ...post, author: { ...post.author, ...newInfo } }
      : post
  );

  // コメントデータのユーザー情報を更新し忘れ!
  // → データの不整合が発生

  setPosts(updatedPosts);
};

// 結果:投稿には新しい情報、コメントには古い情報が残る
// TypeError: Cannot read properties of undefined (reading 'name')
// at Comment.tsx:25:18

このエラーは、実際のプロジェクトでよく発生する問題です。データの更新漏れにより、UI コンポーネントで予期しないundefined値に遭遇し、アプリケーションがクラッシュしてしまいます。

パフォーマンスへの影響

非正規化状態は、パフォーマンスにも深刻な影響を与えます。

typescript// パフォーマンスの問題を引き起こす例
const PostList = () => {
  // 投稿データが変更されるたびに、すべての投稿が再レンダリング
  const posts = useStore((state) => state.posts);

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

// 1つの投稿のいいね数が変更されただけで、
// 全ての投稿コンポーネントが再レンダリングされる

このような問題は、アプリケーションが成長するにつれて顕著になり、ユーザー体験を大幅に悪化させてしまいます。

解決策

正規化設計の基本原則

これらの問題を解決するため、正規化設計の基本原則を適用します。データベース設計でおなじみの概念を、フロントエンドの状態管理に応用するのです。

原則説明利点
単一責任各エンティティは一つの責任を持つ明確な境界線
正規化データの重複を排除整合性の保証
関係性ID を用いた参照関係柔軟なデータ構造

Zustand での Entity 管理パターン

Zustand を使用した Entity 管理の基本パターンをご紹介します。

typescript// エンティティの型定義
interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

interface Post {
  id: string;
  title: string;
  content: string;
  authorId: string; // User への参照
  createdAt: Date;
}

interface Comment {
  id: string;
  content: string;
  postId: string; // Post への参照
  authorId: string; // User への参照
  createdAt: Date;
}

このように、エンティティ間の関係を ID による参照として表現することで、データの重複を排除し、整合性を保つことができます。

状態構造の設計指針

正規化されたストア構造を設計する際の指針をご紹介します。

typescript// 正規化されたストア構造
interface NormalizedStore {
  entities: {
    users: Record<string, User>;
    posts: Record<string, Post>;
    comments: Record<string, Comment>;
  };
  ui: {
    loading: {
      users: boolean;
      posts: boolean;
      comments: boolean;
    };
    error: {
      users: string | null;
      posts: string | null;
      comments: string | null;
    };
  };
}

このような構造により、データと UI の状態を明確に分離し、効率的な状態管理を実現できます。

具体例

基本的な Entity Store 実装

実際の Zustand ストアの実装例をご紹介します。まず、基本的なエンティティストアから始めましょう。

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

// ユーザーストアの実装
interface UserStore {
  users: Record<string, User>;
  addUser: (user: User) => void;
  updateUser: (id: string, updates: Partial<User>) => void;
  removeUser: (id: string) => void;
  getUserById: (id: string) => User | undefined;
}

const useUserStore = create<UserStore>()(
  immer((set, get) => ({
    users: {},

    addUser: (user) =>
      set((state) => {
        state.users[user.id] = user;
      }),

    updateUser: (id, updates) =>
      set((state) => {
        if (state.users[id]) {
          Object.assign(state.users[id], updates);
        }
      }),

    removeUser: (id) =>
      set((state) => {
        delete state.users[id];
      }),

    getUserById: (id) => get().users[id],
  }))
);

このストアでは、Immer ミドルウェアを使用して、不変性を保ちながら直感的な状態更新を実現しています。

正規化されたリレーショナルデータの管理

次に、複数のエンティティ間の関係を管理する実装例をご紹介します。

typescript// メインストアの実装
interface MainStore {
  entities: {
    users: Record<string, User>;
    posts: Record<string, Post>;
    comments: Record<string, Comment>;
  };

  // ユーザー関連のアクション
  addUser: (user: User) => void;
  updateUser: (id: string, updates: Partial<User>) => void;

  // 投稿関連のアクション
  addPost: (post: Post) => void;
  updatePost: (id: string, updates: Partial<Post>) => void;

  // コメント関連のアクション
  addComment: (comment: Comment) => void;
  updateComment: (
    id: string,
    updates: Partial<Comment>
  ) => void;

  // セレクター関数
  getPostWithAuthor: (
    postId: string
  ) => PostWithAuthor | undefined;
  getPostWithComments: (
    postId: string
  ) => PostWithComments | undefined;
}

const useMainStore = create<MainStore>()(
  immer((set, get) => ({
    entities: {
      users: {},
      posts: {},
      comments: {},
    },

    addUser: (user) =>
      set((state) => {
        state.entities.users[user.id] = user;
      }),

    updateUser: (id, updates) =>
      set((state) => {
        if (state.entities.users[id]) {
          Object.assign(state.entities.users[id], updates);
        }
      }),

    addPost: (post) =>
      set((state) => {
        state.entities.posts[post.id] = post;
      }),

    updatePost: (id, updates) =>
      set((state) => {
        if (state.entities.posts[id]) {
          Object.assign(state.entities.posts[id], updates);
        }
      }),

    addComment: (comment) =>
      set((state) => {
        state.entities.comments[comment.id] = comment;
      }),

    updateComment: (id, updates) =>
      set((state) => {
        if (state.entities.comments[id]) {
          Object.assign(
            state.entities.comments[id],
            updates
          );
        }
      }),

    getPostWithAuthor: (postId) => {
      const { entities } = get();
      const post = entities.posts[postId];
      if (!post) return undefined;

      const author = entities.users[post.authorId];
      return author ? { ...post, author } : undefined;
    },

    getPostWithComments: (postId) => {
      const { entities } = get();
      const post = entities.posts[postId];
      if (!post) return undefined;

      const comments = Object.values(entities.comments)
        .filter((comment) => comment.postId === postId)
        .map((comment) => ({
          ...comment,
          author: entities.users[comment.authorId],
        }));

      return { ...post, comments };
    },
  }))
);

CRUD 操作の実装

実際の CRUD 操作を含む、より実践的な実装例をご紹介します。

typescript// API呼び出しと状態更新を組み合わせた実装
interface ApiStore extends MainStore {
  loading: {
    users: boolean;
    posts: boolean;
    comments: boolean;
  };
  error: {
    users: string | null;
    posts: string | null;
    comments: string | null;
  };

  // 非同期操作
  fetchUsers: () => Promise<void>;
  createPost: (
    postData: Omit<Post, 'id' | 'createdAt'>
  ) => Promise<void>;
  deletePost: (postId: string) => Promise<void>;
}

const useApiStore = create<ApiStore>()(
  immer((set, get) => ({
    // 既存のプロパティとメソッド
    entities: {
      users: {},
      posts: {},
      comments: {},
    },

    loading: {
      users: false,
      posts: false,
      comments: false,
    },

    error: {
      users: null,
      posts: null,
      comments: null,
    },

    // 非同期操作の実装
    fetchUsers: async () => {
      set((state) => {
        state.loading.users = true;
        state.error.users = null;
      });

      try {
        const response = await fetch('/api/users');
        if (!response.ok) {
          throw new Error(
            `HTTP error! status: ${response.status}`
          );
        }

        const users = await response.json();

        set((state) => {
          state.loading.users = false;
          users.forEach((user: User) => {
            state.entities.users[user.id] = user;
          });
        });
      } catch (error) {
        set((state) => {
          state.loading.users = false;
          state.error.users =
            error instanceof Error
              ? error.message
              : 'ユーザーの取得に失敗しました';
        });
      }
    },

    createPost: async (postData) => {
      set((state) => {
        state.loading.posts = true;
        state.error.posts = null;
      });

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

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

        const newPost = await response.json();

        set((state) => {
          state.loading.posts = false;
          state.entities.posts[newPost.id] = newPost;
        });
      } catch (error) {
        set((state) => {
          state.loading.posts = false;
          state.error.posts =
            error instanceof Error
              ? error.message
              : '投稿の作成に失敗しました';
        });
      }
    },

    deletePost: async (postId) => {
      set((state) => {
        state.loading.posts = true;
        state.error.posts = null;
      });

      try {
        const response = await fetch(
          `/api/posts/${postId}`,
          {
            method: 'DELETE',
          }
        );

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

        set((state) => {
          state.loading.posts = false;
          delete state.entities.posts[postId];

          // 関連するコメントも削除
          Object.keys(state.entities.comments).forEach(
            (commentId) => {
              if (
                state.entities.comments[commentId]
                  .postId === postId
              ) {
                delete state.entities.comments[commentId];
              }
            }
          );
        });
      } catch (error) {
        set((state) => {
          state.loading.posts = false;
          state.error.posts =
            error instanceof Error
              ? error.message
              : '投稿の削除に失敗しました';
        });
      }
    },

    // 既存のメソッドも含める
    addUser: (user) =>
      set((state) => {
        state.entities.users[user.id] = user;
      }),

    updateUser: (id, updates) =>
      set((state) => {
        if (state.entities.users[id]) {
          Object.assign(state.entities.users[id], updates);
        }
      }),

    // その他のメソッド...
  }))
);

セレクタとデータ取得の最適化

パフォーマンスを最適化するため、適切なセレクタの実装が重要です。

typescript// 最適化されたセレクタの実装
const useOptimizedSelectors = () => {
  // 特定のユーザーのみを取得(他のユーザーが更新されても再レンダリングしない)
  const getUser = useCallback(
    (userId: string) =>
      useApiStore((state) => state.entities.users[userId]),
    []
  );

  // 特定の投稿とその作者のみを取得
  const getPostWithAuthor = useCallback(
    (postId: string) =>
      useApiStore((state) => {
        const post = state.entities.posts[postId];
        if (!post) return undefined;

        const author = state.entities.users[post.authorId];
        return author ? { ...post, author } : undefined;
      }),
    []
  );

  // 投稿一覧を取得(作者情報付き)
  const getPostsWithAuthors = useCallback(
    () =>
      useApiStore((state) => {
        return Object.values(state.entities.posts)
          .map((post) => ({
            ...post,
            author: state.entities.users[post.authorId],
          }))
          .filter((post) => post.author); // 作者情報がある投稿のみ
      }),
    []
  );

  return {
    getUser,
    getPostWithAuthor,
    getPostsWithAuthors,
  };
};

// コンポーネントでの使用例
const PostCard: React.FC<{ postId: string }> = ({
  postId,
}) => {
  const { getPostWithAuthor } = useOptimizedSelectors();
  const postWithAuthor = getPostWithAuthor(postId);

  if (!postWithAuthor) {
    return <div>投稿が見つかりません</div>;
  }

  return (
    <div className='post-card'>
      <h3>{postWithAuthor.title}</h3>
      <p>作者: {postWithAuthor.author.name}</p>
      <p>{postWithAuthor.content}</p>
    </div>
  );
};

このような実装により、必要なデータのみを取得し、不要な再レンダリングを防ぐことができます。

実際の開発では、以下のようなエラーハンドリングも重要です:

typescript// エラーハンドリングの実装例
const ErrorBoundary: React.FC<{
  children: React.ReactNode;
}> = ({ children }) => {
  const error = useApiStore((state) => state.error);

  if (error.posts) {
    return (
      <div className='error-message'>
        <h2>投稿の読み込みでエラーが発生しました</h2>
        <p>{error.posts}</p>
        <button onClick={() => window.location.reload()}>
          ページを再読み込み
        </button>
      </div>
    );
  }

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

// 実際のエラーメッセージ例
// "Network request failed: TypeError: Failed to fetch"
// "HTTP error! status: 404"
// "JSON parse error: Unexpected token '<' at position 0"

これらの実装により、堅牢で保守性の高い状態管理システムを構築できます。

まとめ

本記事では、Zustand を使用した Entity 管理と正規化設計について詳しく解説いたしました。

従来の非正規化な状態管理から脱却し、正規化された構造を採用することで、以下のような大きなメリットを得ることができます:

技術的なメリット:

  • データの整合性の保証
  • パフォーマンスの大幅な向上
  • 保守性の向上
  • バグの削減

開発体験の改善:

  • 直感的な状態更新
  • TypeScript との優れた統合
  • テストの容易性
  • チーム開発での理解しやすさ

アプリケーションの成長への対応:

  • スケーラブルな設計
  • 機能追加の容易さ
  • 技術的負債の削減

これらの実装パターンを習得することで、皆様の React アプリケーションはより堅牢で保守性の高いものとなるでしょう。

状態管理は一見複雑に見えるかもしれませんが、適切な設計原則に従うことで、長期的に開発効率を大幅に改善することができます。ぜひ実際のプロジェクトで試してみてください。

きっと、コードの品質向上と開発体験の改善を実感していただけるはずです。

関連リンク