Day 3: ステート設計
今日学ぶこと
- ステートの形状がアプリケーション全体に与える影響
- ネストされたステートと正規化されたステートの違い
createEntityAdapterを使った CRUD 操作の効率化- スライスの分割戦略(機能別 vs データ型別)
- 派生データの計算パターン
- ステート設計のベストプラクティスとアンチパターン
なぜステートの形状が重要なのか
Redux のステートは、アプリケーション全体の「信頼できる唯一の情報源(Single Source of Truth)」です。ステートの形状を適切に設計することで、以下のメリットが得られます。
- パフォーマンス: 更新時に不要な再レンダリングを防げる
- 保守性: コードの可読性と変更しやすさが向上する
- バグの防止: データの不整合が起きにくくなる
- スケーラビリティ: アプリケーションの成長に対応しやすくなる
逆に、ステート設計を誤ると、以下のような問題が発生します。
- 同じデータの重複による不整合
- 深いネストによる更新の複雑化
- 不要な再計算や再レンダリング
ネストされたステート vs 正規化されたステート
ネストされたステート(よくある間違い)
ブログアプリを例に考えてみましょう。まず、APIのレスポンスをそのままステートに入れた場合です。
// Bad: ネストされたステート
const state = {
posts: [
{
id: 1,
title: "Reduxの基礎",
author: {
id: 101,
name: "田中太郎",
avatar: "/avatars/tanaka.png"
},
comments: [
{
id: 1001,
text: "素晴らしい記事です!",
author: {
id: 102,
name: "佐藤花子",
avatar: "/avatars/sato.png"
}
},
{
id: 1002,
text: "参考になりました",
author: {
id: 101,
name: "田中太郎",
avatar: "/avatars/tanaka.png"
}
}
]
}
]
};
この設計には複数の問題があります。
- データの重複: 「田中太郎」の情報が複数箇所に存在する
- 更新の困難さ: ユーザーのアバターを変更するとき、全ての出現箇所を更新する必要がある
- 検索の非効率さ: 特定のコメントを探すにはネストを辿る必要がある
正規化されたステート(推奨)
// Good: 正規化されたステート
const state = {
users: {
ids: [101, 102],
entities: {
101: { id: 101, name: "田中太郎", avatar: "/avatars/tanaka.png" },
102: { id: 102, name: "佐藤花子", avatar: "/avatars/sato.png" }
}
},
posts: {
ids: [1],
entities: {
1: {
id: 1,
title: "Reduxの基礎",
authorId: 101,
commentIds: [1001, 1002]
}
}
},
comments: {
ids: [1001, 1002],
entities: {
1001: { id: 1001, text: "素晴らしい記事です!", authorId: 102, postId: 1 },
1002: { id: 1002, text: "参考になりました", authorId: 101, postId: 1 }
}
}
};
flowchart LR
subgraph Nested["ネストされたステート"]
direction TB
P1["Post"]
A1["Author (埋め込み)"]
C1["Comment"]
C1A["Author (重複!)"]
P1 --> A1
P1 --> C1
C1 --> C1A
end
subgraph Normalized["正規化されたステート"]
direction TB
NP["Posts\n{ids, entities}"]
NU["Users\n{ids, entities}"]
NC["Comments\n{ids, entities}"]
NP -->|authorId| NU
NP -->|commentIds| NC
NC -->|authorId| NU
end
style Nested fill:#ef4444,color:#fff
style Normalized fill:#22c55e,color:#fff
正規化された設計のメリットは以下の通りです。
- データの重複がない: 各エンティティは1箇所にのみ存在する
- 更新が簡単: 1箇所を変更すれば全体に反映される
- O(1)のルックアップ: IDでオブジェクトに直接アクセスできる
- 順序の管理:
ids配列で表示順序を管理できる
エンティティパターン: { ids: [], entities: {} }
正規化されたステートの標準的なデータ構造は以下の形式です。
{
ids: [1, 2, 3], // IDの配列(順序を保持)
entities: { // IDをキーとしたオブジェクト
1: { id: 1, name: "..." },
2: { id: 2, name: "..." },
3: { id: 3, name: "..." }
}
}
この構造が優れている理由は次の通りです。
| 操作 | 配列のみ | エンティティパターン |
|---|---|---|
| IDで検索 | O(n) | O(1) |
| 追加 | O(1) | O(1) |
| 更新 | O(n) | O(1) |
| 削除 | O(n) | O(1) |
| 順序の維持 | 自然 | ids 配列で管理 |
| 一覧の取得 | 自然 | ids.map(id => entities[id]) |
createEntityAdapter
Redux Toolkit は、エンティティパターンを簡単に実装するための createEntityAdapter を提供しています。
基本的な使い方
import { createSlice, createEntityAdapter } from "@reduxjs/toolkit";
// 1. アダプターを作成
const usersAdapter = createEntityAdapter();
// 2. 初期ステートを生成
const initialState = usersAdapter.getInitialState({
// 追加のステートフィールド
loading: false,
error: null
});
// => { ids: [], entities: {}, loading: false, error: null }
// 3. スライスで使用
const usersSlice = createSlice({
name: "users",
initialState,
reducers: {
userAdded: usersAdapter.addOne,
userUpdated: usersAdapter.updateOne,
userRemoved: usersAdapter.removeOne,
usersReceived: usersAdapter.setAll
}
});
export const { userAdded, userUpdated, userRemoved, usersReceived } =
usersSlice.actions;
export default usersSlice.reducer;
TypeScript版
import {
createSlice,
createEntityAdapter,
PayloadAction,
EntityState
} from "@reduxjs/toolkit";
interface User {
id: number;
name: string;
email: string;
role: "admin" | "user";
}
interface UsersState extends EntityState<User, number> {
loading: boolean;
error: string | null;
}
const usersAdapter = createEntityAdapter<User>();
const initialState: UsersState = usersAdapter.getInitialState({
loading: false,
error: null
});
const usersSlice = createSlice({
name: "users",
initialState,
reducers: {
userAdded: usersAdapter.addOne,
userUpdated: usersAdapter.updateOne,
userRemoved: usersAdapter.removeOne,
usersReceived: usersAdapter.setAll
}
});
export const { userAdded, userUpdated, userRemoved, usersReceived } =
usersSlice.actions;
export default usersSlice.reducer;
CRUD 操作一覧
createEntityAdapter は以下の CRUD メソッドを提供します。
const adapter = createEntityAdapter();
// === 追加 ===
adapter.addOne(state, entity); // 1つのエンティティを追加
adapter.addMany(state, entities); // 複数のエンティティを追加
// === 更新 ===
adapter.updateOne(state, { id, changes }); // 1つを部分更新
adapter.updateMany(state, updates); // 複数を部分更新
adapter.upsertOne(state, entity); // 存在すれば更新、なければ追加
adapter.upsertMany(state, entities); // 複数をupsert
// === 削除 ===
adapter.removeOne(state, id); // 1つを削除
adapter.removeMany(state, ids); // 複数を削除
adapter.removeAll(state); // 全て削除
// === 設定 ===
adapter.setOne(state, entity); // 1つを完全に置き換え
adapter.setMany(state, entities); // 複数を完全に置き換え
adapter.setAll(state, entities); // 全てを置き換え
実際の使用例
import { useDispatch, useSelector } from "react-redux";
import { userAdded, userUpdated, userRemoved, usersReceived } from "./usersSlice";
function UserManager() {
const dispatch = useDispatch();
// ユーザーを追加
const handleAddUser = () => {
dispatch(userAdded({
id: Date.now(),
name: "新しいユーザー",
email: "new@example.com",
role: "user"
}));
};
// ユーザーを更新(部分更新)
const handleUpdateUser = (id) => {
dispatch(userUpdated({
id,
changes: { name: "更新された名前" }
}));
};
// ユーザーを削除
const handleRemoveUser = (id) => {
dispatch(userRemoved(id));
};
// 全ユーザーを設定(API レスポンスなど)
const handleSetAll = (users) => {
dispatch(usersReceived(users));
};
return (
<div>
<button onClick={handleAddUser}>ユーザーを追加</button>
</div>
);
}
セレクターの生成
createEntityAdapter はセレクターも自動生成します。
// セレクターを生成
const usersSelectors = usersAdapter.getSelectors(
(state) => state.users
);
// 利用可能なセレクター
usersSelectors.selectAll(state); // 全エンティティの配列を返す
usersSelectors.selectById(state, id); // IDで1つのエンティティを返す
usersSelectors.selectIds(state); // 全IDの配列を返す
usersSelectors.selectEntities(state); // エンティティオブジェクトを返す
usersSelectors.selectTotal(state); // エンティティの総数を返す
import { useSelector } from "react-redux";
function UserList() {
const allUsers = useSelector(usersSelectors.selectAll);
const totalUsers = useSelector(usersSelectors.selectTotal);
const specificUser = useSelector((state) =>
usersSelectors.selectById(state, 101)
);
return (
<div>
<h2>ユーザー一覧({totalUsers}人)</h2>
<ul>
{allUsers.map((user) => (
<li key={user.id}>{user.name} - {user.email}</li>
))}
</ul>
</div>
);
}
ソート順のカスタマイズ
const usersAdapter = createEntityAdapter({
// カスタムIDフィールド(デフォルトは "id")
selectId: (user) => user.userId,
// ソート順を指定
sortComparer: (a, b) => a.name.localeCompare(b.name)
});
スライスの分割戦略
アプリケーションが大きくなると、ステートをどのようにスライスに分割するかが重要になります。
戦略1: データ型別(推奨)
// store.js
import { configureStore } from "@reduxjs/toolkit";
import usersReducer from "./features/users/usersSlice";
import postsReducer from "./features/posts/postsSlice";
import commentsReducer from "./features/comments/commentsSlice";
const store = configureStore({
reducer: {
users: usersReducer,
posts: postsReducer,
comments: commentsReducer
}
});
flowchart TB
subgraph Store["Redux Store"]
direction LR
subgraph Users["users"]
U["ids, entities"]
end
subgraph Posts["posts"]
P["ids, entities"]
end
subgraph Comments["comments"]
C["ids, entities"]
end
end
style Store fill:#3b82f6,color:#fff
style Users fill:#8b5cf6,color:#fff
style Posts fill:#8b5cf6,color:#fff
style Comments fill:#8b5cf6,color:#fff
メリット: エンティティごとの管理が明確で、再利用しやすい。
戦略2: 機能別
const store = configureStore({
reducer: {
auth: authReducer, // ログイン、ユーザー情報
blog: blogReducer, // 記事、コメント
dashboard: dashboardReducer // ダッシュボード固有のステート
}
});
メリット: 機能単位での理解がしやすい。
どちらを選ぶか
| 基準 | データ型別 | 機能別 |
|---|---|---|
| データの共有 | しやすい | 機能間で重複しやすい |
| スケーラビリティ | 高い | 中程度 |
| 理解しやすさ | データモデルが明確 | 機能の境界が明確 |
| 推奨ケース | 多くのアプリ | 独立性の高い機能 |
実際のプロジェクトでは、データ型別を基本にしつつ、UI固有のステートは機能別に管理するハイブリッド方式がよく使われます。
const store = configureStore({
reducer: {
// データ型別(エンティティ)
users: usersReducer,
posts: postsReducer,
comments: commentsReducer,
// 機能別(UIステート)
auth: authReducer,
ui: uiReducer
}
});
ブログアプリのステート設計例
実際のブログアプリケーションを例に、正規化されたステート設計を見てみましょう。
スライスの定義
// features/posts/postsSlice.js
import { createSlice, createEntityAdapter } from "@reduxjs/toolkit";
const postsAdapter = createEntityAdapter({
sortComparer: (a, b) => b.createdAt.localeCompare(a.createdAt)
});
const initialState = postsAdapter.getInitialState({
status: "idle",
error: null,
currentPostId: null
});
const postsSlice = createSlice({
name: "posts",
initialState,
reducers: {
postAdded: postsAdapter.addOne,
postUpdated: postsAdapter.updateOne,
postRemoved: postsAdapter.removeOne,
postsLoaded: postsAdapter.setAll,
currentPostSet(state, action) {
state.currentPostId = action.payload;
}
}
});
export const postsSelectors = postsAdapter.getSelectors(
(state) => state.posts
);
export const selectCurrentPost = (state) =>
postsSelectors.selectById(state, state.posts.currentPostId);
export const { postAdded, postUpdated, postRemoved, postsLoaded, currentPostSet } =
postsSlice.actions;
export default postsSlice.reducer;
// features/comments/commentsSlice.js
import { createSlice, createEntityAdapter } from "@reduxjs/toolkit";
const commentsAdapter = createEntityAdapter({
sortComparer: (a, b) => a.createdAt.localeCompare(b.createdAt)
});
const initialState = commentsAdapter.getInitialState();
const commentsSlice = createSlice({
name: "comments",
initialState,
reducers: {
commentAdded: commentsAdapter.addOne,
commentRemoved: commentsAdapter.removeOne,
commentsForPostLoaded: commentsAdapter.upsertMany
}
});
// 特定の投稿に属するコメントを選択するセレクター
export const commentsSelectors = commentsAdapter.getSelectors(
(state) => state.comments
);
export const selectCommentsByPostId = (state, postId) => {
const allComments = commentsSelectors.selectAll(state);
return allComments.filter((comment) => comment.postId === postId);
};
export const { commentAdded, commentRemoved, commentsForPostLoaded } =
commentsSlice.actions;
export default commentsSlice.reducer;
// features/users/usersSlice.js
import { createSlice, createEntityAdapter } from "@reduxjs/toolkit";
const usersAdapter = createEntityAdapter();
const initialState = usersAdapter.getInitialState({
currentUserId: null
});
const usersSlice = createSlice({
name: "users",
initialState,
reducers: {
userLoggedIn(state, action) {
usersAdapter.upsertOne(state, action.payload);
state.currentUserId = action.payload.id;
},
userLoggedOut(state) {
state.currentUserId = null;
},
usersLoaded: usersAdapter.setAll
}
});
export const usersSelectors = usersAdapter.getSelectors(
(state) => state.users
);
export const selectCurrentUser = (state) =>
usersSelectors.selectById(state, state.users.currentUserId);
export const { userLoggedIn, userLoggedOut, usersLoaded } =
usersSlice.actions;
export default usersSlice.reducer;
ストアの構成
// app/store.js
import { configureStore } from "@reduxjs/toolkit";
import postsReducer from "../features/posts/postsSlice";
import commentsReducer from "../features/comments/commentsSlice";
import usersReducer from "../features/users/usersSlice";
const store = configureStore({
reducer: {
posts: postsReducer,
comments: commentsReducer,
users: usersReducer
}
});
export default store;
派生データの計算
ステートに保存するのは「基本データ」のみにし、表示に必要な値は計算で導出します。
悪い例: 派生データをステートに保存
// Bad: 派生データをステートに保存している
const state = {
items: [
{ id: 1, name: "商品A", price: 1000, quantity: 2 },
{ id: 2, name: "商品B", price: 500, quantity: 3 }
],
totalItems: 5, // items から計算可能
totalPrice: 3500, // items から計算可能
isEmpty: false // items.length から計算可能
};
良い例: セレクターで計算
// Good: 基本データのみ保存し、派生データはセレクターで計算
const cartSlice = createSlice({
name: "cart",
initialState: {
ids: [],
entities: {}
},
reducers: {
itemAdded: cartAdapter.addOne,
itemRemoved: cartAdapter.removeOne,
quantityUpdated: cartAdapter.updateOne
}
});
// 派生データはセレクターで計算
const cartSelectors = cartAdapter.getSelectors((state) => state.cart);
const selectCartItems = cartSelectors.selectAll;
const selectTotalItems = (state) => {
const items = selectCartItems(state);
return items.reduce((sum, item) => sum + item.quantity, 0);
};
const selectTotalPrice = (state) => {
const items = selectCartItems(state);
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
};
const selectIsCartEmpty = (state) => {
return cartSelectors.selectTotal(state) === 0;
};
ヒント: Day 6 で学ぶ
createSelector(Reselect)を使うと、派生データのメモ化(キャッシュ)も可能になります。
ステート設計のベストプラクティス
まとめ
| ポイント | 推奨 | 避けるべき |
|---|---|---|
| データ構造 | 正規化 {ids, entities} |
深いネスト |
| データの保存 | 各エンティティは1箇所のみ | 同じデータの複数箇所での保存 |
| 派生データ | セレクターで計算 | ステートに保存 |
| ID参照 | authorId: 101 |
オブジェクトの埋め込み |
| CRUD操作 | createEntityAdapter |
手動でのイミュータブル更新 |
| スライス分割 | データ型別 + UIステート | 全てを1つのスライスに |
| ソート・フィルタ | セレクターで実行 | ステートに保存された結果 |
よくあるアンチパターン
1. データの重複
// Bad: ユーザー情報が投稿とコメントの両方に埋め込まれている
posts: [{ id: 1, author: { id: 101, name: "田中" } }]
comments: [{ id: 1, author: { id: 101, name: "田中" } }]
// Good: IDで参照
posts: { entities: { 1: { id: 1, authorId: 101 } } }
users: { entities: { 101: { id: 101, name: "田中" } } }
2. 深いネスト
// Bad: 3階層以上のネスト
state.departments[0].teams[1].members[2].tasks[0].status = "done";
// Good: フラットな構造
state.tasks.entities[taskId].status = "done";
3. 派生データの保存
// Bad: 合計値をステートに保存
{ items: [...], totalCount: 5, totalPrice: 3500 }
// Good: セレクターで計算
const selectTotalPrice = (state) =>
selectAllItems(state).reduce((sum, item) => sum + item.price * item.quantity, 0);
練習問題
問題1: ステートの正規化
以下のネストされたステートを正規化してください。
const state = {
departments: [
{
id: "d1",
name: "Engineering",
manager: { id: "u1", name: "Alice", email: "alice@example.com" },
members: [
{ id: "u2", name: "Bob", email: "bob@example.com" },
{ id: "u3", name: "Charlie", email: "charlie@example.com" }
]
},
{
id: "d2",
name: "Design",
manager: { id: "u4", name: "Diana", email: "diana@example.com" },
members: [
{ id: "u5", name: "Eve", email: "eve@example.com" }
]
}
]
};
問題2: createEntityAdapter の実装
タスク管理アプリのスライスを createEntityAdapter を使って実装してください。タスクは以下のフィールドを持ちます。
id: 一意のIDtitle: タスク名completed: 完了フラグpriority: 優先度("high", "medium", "low")createdAt: 作成日時
以下の機能を実装してください。
- タスクの追加(
taskAdded) - タスクの完了/未完了の切り替え(
taskToggled) - 完了済みタスクの一括削除(
completedTasksCleared) - 優先度でソートされた状態で管理
- 未完了タスクのみを返すセレクター
問題3: ブログアプリのステート設計
以下の要件を満たすブログアプリのステートを正規化された形式で設計してください。
- 記事(posts): タイトル、本文、著者、タグ、作成日
- タグ(tags): 名前、記事数
- 著者(authors): 名前、プロフィール画像、自己紹介
- コメント(comments): 本文、著者、記事、作成日
- いいね(likes): ユーザー、記事
各エンティティの関連をどのようにIDで表現するか、スライスの分割をどうするか考えてみましょう。