状態の構造化: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 アプリケーションはより堅牢で保守性の高いものとなるでしょう。
状態管理は一見複雑に見えるかもしれませんが、適切な設計原則に従うことで、長期的に開発効率を大幅に改善することができます。ぜひ実際のプロジェクトで試してみてください。
きっと、コードの品質向上と開発体験の改善を実感していただけるはずです。
関連リンク
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来