10日で覚えるReduxDay 3: ステート設計
books.chapter 310日で覚えるRedux

Day 3: ステート設計

今日学ぶこと

  • ステートの形状がアプリケーション全体に与える影響
  • ネストされたステートと正規化されたステートの違い
  • createEntityAdapter を使った CRUD 操作の効率化
  • スライスの分割戦略(機能別 vs データ型別)
  • 派生データの計算パターン
  • ステート設計のベストプラクティスとアンチパターン

なぜステートの形状が重要なのか

Redux のステートは、アプリケーション全体の「信頼できる唯一の情報源(Single Source of Truth)」です。ステートの形状を適切に設計することで、以下のメリットが得られます。

  1. パフォーマンス: 更新時に不要な再レンダリングを防げる
  2. 保守性: コードの可読性と変更しやすさが向上する
  3. バグの防止: データの不整合が起きにくくなる
  4. スケーラビリティ: アプリケーションの成長に対応しやすくなる

逆に、ステート設計を誤ると、以下のような問題が発生します。

  • 同じデータの重複による不整合
  • 深いネストによる更新の複雑化
  • 不要な再計算や再レンダリング

ネストされたステート 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: 一意のID
  • title: タスク名
  • completed: 完了フラグ
  • priority: 優先度("high", "medium", "low")
  • createdAt: 作成日時

以下の機能を実装してください。

  1. タスクの追加(taskAdded
  2. タスクの完了/未完了の切り替え(taskToggled
  3. 完了済みタスクの一括削除(completedTasksCleared
  4. 優先度でソートされた状態で管理
  5. 未完了タスクのみを返すセレクター

問題3: ブログアプリのステート設計

以下の要件を満たすブログアプリのステートを正規化された形式で設計してください。

  • 記事(posts): タイトル、本文、著者、タグ、作成日
  • タグ(tags): 名前、記事数
  • 著者(authors): 名前、プロフィール画像、自己紹介
  • コメント(comments): 本文、著者、記事、作成日
  • いいね(likes): ユーザー、記事

各エンティティの関連をどのようにIDで表現するか、スライスの分割をどうするか考えてみましょう。