Redux

Redux 完全ガイド:JavaScript状態管理の決定版

対象読者: JavaScript/TypeScript開発者、Reactアプリケーション開発者
前提知識: JavaScript (ES6+)、React の基本的な知識
Redux Toolkit バージョン: 2.x / React-Redux 9.x 対応


1. はじめに

1.1 Redux とは何か

Redux は、JavaScript アプリケーションのための予測可能な状態管理ライブラリである。アプリケーション全体の状態(state)を一つの集中的なストア(store)で管理し、状態の変更を明示的かつ追跡可能な方法で行うことを可能にする。

Redux の核心は、驚くほどシンプルな考え方に基づいている。

// Redux の本質:状態 + アクション → 新しい状態
(state, action) => newState

この単純な関数型プログラミングのパターンが、大規模アプリケーションの状態管理を劇的に改善する。

1.2 Redux が解決する問題

モダンなフロントエンドアプリケーションでは、管理すべき状態が多岐にわたる。

アプリケーションの状態の種類:
├── サーバーデータ(APIレスポンス、キャッシュ)
├── UI状態(モーダルの開閉、タブの選択状態)
├── フォーム状態(入力値、バリデーション結果)
├── ルーティング状態(現在のURL、履歴)
├── 認証状態(ログインユーザー、トークン)
└── 通知・アラート状態(メッセージキュー)

Redux がない世界では、以下のような問題が発生する。

問題1: Prop Drilling(プロップの深層受け渡し)

// Redux なし:深いコンポーネントツリーでの状態受け渡し
function App() {
  const [user, setUser] = useState(null);
  return <Dashboard user={user} setUser={setUser} />;
}

function Dashboard({ user, setUser }) {
  return <Sidebar user={user} setUser={setUser} />;
}

function Sidebar({ user, setUser }) {
  return <UserMenu user={user} setUser={setUser} />;
}

function UserMenu({ user, setUser }) {
  // ようやくここで使用される
  return <span>{user?.name}</span>;
}

問題2: 状態の不整合

// 複数のコンポーネントが独立して同じデータを管理
function ProductList() {
  const [products, setProducts] = useState([]); // リスト A
  // ...
}

function Cart() {
  const [cartItems, setCartItems] = useState([]); // リスト B
  // products の在庫数と cartItems の整合性を手動で管理する必要がある
}

問題3: 状態変更の追跡困難

// どこで、いつ、なぜ状態が変わったのか分からない
useEffect(() => {
  setUser(updatedUser);       // ここで変わった?
}, [someCondition]);

const handleClick = () => {
  setUser(null);              // それともここ?
};

someCallback(() => {
  setUser(anotherUser);       // あるいはここ?
});

Redux はこれらの問題を、単一のストア明示的なアクション純粋関数による状態変更という3つの原則で解決する。

1.3 Redux の歴史

Redux は2015年、Dan AbramovAndrew Clark によって作成された。

時期出来事
2014年Facebook が Flux アーキテクチャを発表
2015年5月Dan Abramov が React Europe で Hot Reloading のデモ中に Redux の原型を開発
2015年6月Redux 0.1.0 リリース
2015年8月Redux 1.0.0 リリース
2018年Redux 4.0 リリース(TypeScript サポート強化)
2019年10月Redux Toolkit 1.0 リリース(公式推奨ツールセット)
2023年Redux Toolkit 2.0 / Redux 5.0 リリース(ESM対応、TypeScript改善)
2024年React-Redux 9.0 リリース(React 18+ 最適化)

Redux は Facebook の Flux アーキテクチャと関数型プログラミング言語 Elm から強い影響を受けている。

Flux の影響:
├── 単方向データフロー
├── Dispatcher の概念(→ Redux では store.dispatch)
└── Store の概念

Elm の影響:
├── 不変性(Immutability)
├── 純粋関数による状態更新
└── Model-Update-View パターン

1.4 Redux のバージョン変遷と現在の推奨

現在の Redux 開発では、Redux Toolkit(RTK)が唯一の公式推奨アプローチである。レガシーな手書きの Redux コードは非推奨となっている。

// レガシー Redux(非推奨)
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

const store = createStore(
  combineReducers({ todos: todosReducer }),
  applyMiddleware(thunk)
);

// モダン Redux(推奨)
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './features/todos/todosSlice';

const store = configureStore({
  reducer: { todos: todosReducer },
  // thunk ミドルウェアは自動的に含まれる
});

本ガイドでは、モダンな Redux Toolkit を中心に解説しつつ、内部の仕組みの理解に必要な範囲でコアの Redux API にも触れていく。


2. コアコンセプト

2.1 Redux の三原則

Redux のアーキテクチャは、3つの基本原則に基づいている。

原則1: Single Source of Truth(信頼できる唯一の情報源)

アプリケーション全体の状態は、単一のストア内のオブジェクトツリーに格納される。

// アプリケーション全体の状態が一つのオブジェクトに集約される
const appState = {
  auth: {
    user: { id: 1, name: '田中太郎', email: 'tanaka@example.com' },
    isAuthenticated: true,
    token: 'eyJhbGciOiJIUzI1NiIs...',
  },
  todos: {
    items: [
      { id: 1, text: 'Redux を学ぶ', completed: false },
      { id: 2, text: 'RTK Query を試す', completed: true },
    ],
    filter: 'active',
    loading: false,
  },
  notifications: {
    items: [
      { id: 1, message: '新しいタスクが追加されました', read: false },
    ],
    unreadCount: 1,
  },
};

この設計により、以下の利点が得られる。

  • デバッグの容易さ: 状態全体のスナップショットを取得・比較できる
  • サーバーサイドレンダリング: サーバーで生成した状態をクライアントに簡単に引き渡せる
  • 永続化: 状態全体を localStorage などに保存・復元できる
  • テスト: 特定の状態を再現してテストできる

原則2: State is Read-Only(状態は読み取り専用)

状態を変更する唯一の方法は、**アクション(action)**を発行(dispatch)することである。

// 直接的な状態変更は禁止
store.getState().todos.items.push({ id: 3, text: '新しいタスク' }); // NG!

// アクションを dispatch して状態を変更する
store.dispatch({
  type: 'todos/addTodo',
  payload: { id: 3, text: '新しいタスク', completed: false },
});

// UI イベントからのアクション発行
store.dispatch({ type: 'todos/toggleTodo', payload: { id: 1 } });
store.dispatch({ type: 'todos/setFilter', payload: 'completed' });
store.dispatch({ type: 'auth/logout' });

アクションは「何が起きたか」を記述するプレーンオブジェクトであり、状態変更の完全な履歴を形成する。

原則3: Changes are Made with Pure Functions(純粋関数による変更)

状態がアクションによってどのように変化するかを記述するために、**Reducer(リデューサー)**と呼ばれる純粋関数を使用する。

// Reducer は純粋関数: (previousState, action) => newState
function todosReducer(
  state: TodosState = initialState,
  action: TodoAction
): TodosState {
  switch (action.type) {
    case 'todos/addTodo':
      return {
        ...state,
        items: [...state.items, action.payload],
      };
    case 'todos/toggleTodo':
      return {
        ...state,
        items: state.items.map((todo) =>
          todo.id === action.payload.id
            ? { ...todo, completed: !todo.completed }
            : todo
        ),
      };
    case 'todos/setFilter':
      return {
        ...state,
        filter: action.payload,
      };
    default:
      return state;
  }
}

純粋関数であるため、以下の条件を守る必要がある。

// Reducer のルール
// 1. 同じ入力に対して常に同じ出力を返す
// 2. 副作用を持たない(API 呼び出し、乱数生成、Date.now() の使用は禁止)
// 3. 引数を直接変更しない(イミュータブルな更新)

// NG: 副作用のある Reducer
function badReducer(state, action) {
  state.items.push(action.payload);     // 直接変更(NG)
  fetch('/api/save', { body: state });  // API 呼び出し(NG)
  state.timestamp = Date.now();         // 非決定的(NG)
  return state;
}

// OK: 純粋な Reducer
function goodReducer(state, action) {
  return {
    ...state,
    items: [...state.items, action.payload],
    // timestamp はアクション作成時に設定済み
    timestamp: action.payload.timestamp,
  };
}

2.2 Store(ストア)

Store はアプリケーションの状態を保持するオブジェクトである。

import { configureStore } from '@reduxjs/toolkit';

// Store の作成
const store = configureStore({
  reducer: {
    todos: todosReducer,
    auth: authReducer,
    notifications: notificationsReducer,
  },
});

// Store の主要な API
// 1. getState(): 現在の状態を取得
const currentState = store.getState();
console.log(currentState.todos.items); // [...todos]

// 2. dispatch(action): アクションを発行して状態を更新
store.dispatch({ type: 'todos/addTodo', payload: newTodo });

// 3. subscribe(listener): 状態変更時のコールバックを登録
const unsubscribe = store.subscribe(() => {
  console.log('状態が更新されました:', store.getState());
});

// 購読解除
unsubscribe();

// 4. getState() で更新後の状態を取得
store.dispatch({ type: 'todos/addTodo', payload: newTodo });
const updatedState = store.getState();

2.3 Actions(アクション)

アクションは、状態変更の意図を記述するプレーンオブジェクトである。

// アクションの基本構造
interface Action {
  type: string;      // 必須: アクションの種類を識別する文字列
  payload?: any;     // 任意: アクションに付随するデータ
  meta?: any;        // 任意: 追加のメタ情報
  error?: boolean;   // 任意: エラーアクションかどうか
}

// 具体的なアクションの例
const addTodoAction = {
  type: 'todos/addTodo',
  payload: {
    id: 3,
    text: 'TypeScript を学ぶ',
    completed: false,
  },
};

const setFilterAction = {
  type: 'todos/setFilter',
  payload: 'completed',
};

const logoutAction = {
  type: 'auth/logout',
  // payload は不要な場合もある
};

// エラーアクション(FSA: Flux Standard Action 規約)
const fetchFailedAction = {
  type: 'todos/fetchTodos/rejected',
  payload: new Error('ネットワークエラー'),
  error: true,
};

**Action Creator(アクション作成関数)**は、アクションオブジェクトを返す関数である。

// 手動のアクション作成関数
function addTodo(text: string) {
  return {
    type: 'todos/addTodo' as const,
    payload: {
      id: Date.now(),
      text,
      completed: false,
    },
  };
}

// 使用例
store.dispatch(addTodo('Redux を学ぶ'));

// Redux Toolkit の createAction を使用(推奨)
import { createAction } from '@reduxjs/toolkit';

const addTodo = createAction<{ text: string }>('todos/addTodo');
const toggleTodo = createAction<{ id: number }>('todos/toggleTodo');
const setFilter = createAction<string>('todos/setFilter');

// 自動的に type プロパティと payload が設定される
console.log(addTodo({ text: 'RTK を使う' }));
// { type: 'todos/addTodo', payload: { text: 'RTK を使う' } }

// Prepare callback でペイロードをカスタマイズ
const addTodoWithId = createAction('todos/addTodo', (text: string) => ({
  payload: {
    id: Date.now(),
    text,
    completed: false,
    createdAt: new Date().toISOString(),
  },
}));

2.4 Reducers(リデューサー)

Reducer は、現在の状態とアクションを受け取り、新しい状態を返す純粋関数である。

// 基本的な Reducer の構造
interface TodosState {
  items: Todo[];
  filter: 'all' | 'active' | 'completed';
  loading: boolean;
  error: string | null;
}

const initialState: TodosState = {
  items: [],
  filter: 'all',
  loading: false,
  error: null,
};

// switch 文による伝統的な Reducer
function todosReducer(
  state = initialState,
  action: AnyAction
): TodosState {
  switch (action.type) {
    case 'todos/addTodo':
      return {
        ...state,
        items: [
          ...state.items,
          {
            id: action.payload.id,
            text: action.payload.text,
            completed: false,
          },
        ],
      };

    case 'todos/toggleTodo':
      return {
        ...state,
        items: state.items.map((todo) =>
          todo.id === action.payload.id
            ? { ...todo, completed: !todo.completed }
            : todo
        ),
      };

    case 'todos/removeTodo':
      return {
        ...state,
        items: state.items.filter((todo) => todo.id !== action.payload.id),
      };

    case 'todos/setFilter':
      return {
        ...state,
        filter: action.payload,
      };

    case 'todos/fetchTodos/pending':
      return { ...state, loading: true, error: null };

    case 'todos/fetchTodos/fulfilled':
      return { ...state, loading: false, items: action.payload };

    case 'todos/fetchTodos/rejected':
      return { ...state, loading: false, error: action.payload };

    default:
      return state;
  }
}

**Reducer の合成(combineReducers)**により、状態ツリーを分割管理する。

import { combineReducers } from '@reduxjs/toolkit';

// 各ドメインごとに独立した Reducer を作成
const rootReducer = combineReducers({
  todos: todosReducer,
  auth: authReducer,
  notifications: notificationsReducer,
  ui: uiReducer,
});

// 結果の状態ツリー
type RootState = ReturnType<typeof rootReducer>;
// {
//   todos: TodosState;
//   auth: AuthState;
//   notifications: NotificationsState;
//   ui: UIState;
// }

2.5 Dispatch(ディスパッチ)

Dispatch は、アクションをストアに送信するための唯一の方法である。

// 基本的な dispatch
store.dispatch({ type: 'todos/addTodo', payload: { text: '新しいタスク' } });

// Action Creator を使った dispatch
store.dispatch(addTodo('新しいタスク'));

// 条件付き dispatch
function addTodoIfNotEmpty(text: string) {
  return (dispatch: AppDispatch, getState: () => RootState) => {
    if (text.trim().length > 0) {
      dispatch(addTodo(text));
    }
  };
}

// バッチ dispatch(複数のアクションを連続して dispatch)
function resetAndReload() {
  return (dispatch: AppDispatch) => {
    dispatch(clearTodos());
    dispatch(setFilter('all'));
    dispatch(fetchTodos());
  };
}

2.6 Selectors(セレクター)

Selector は、ストアの状態から特定のデータを抽出する関数である。

// 基本的なセレクター
const selectTodos = (state: RootState) => state.todos.items;
const selectFilter = (state: RootState) => state.todos.filter;
const selectIsLoading = (state: RootState) => state.todos.loading;

// 派生データを計算するセレクター
const selectFilteredTodos = (state: RootState) => {
  const todos = selectTodos(state);
  const filter = selectFilter(state);

  switch (filter) {
    case 'active':
      return todos.filter((todo) => !todo.completed);
    case 'completed':
      return todos.filter((todo) => todo.completed);
    default:
      return todos;
  }
};

// 集計セレクター
const selectTodoStats = (state: RootState) => {
  const todos = selectTodos(state);
  const total = todos.length;
  const completed = todos.filter((t) => t.completed).length;
  const active = total - completed;
  const percentComplete = total > 0 ? Math.round((completed / total) * 100) : 0;

  return { total, completed, active, percentComplete };
};

// パラメータ付きセレクター
const selectTodoById = (state: RootState, todoId: number) =>
  state.todos.items.find((todo) => todo.id === todoId);

// React コンポーネントでの使用
function TodoList() {
  const filteredTodos = useSelector(selectFilteredTodos);
  const stats = useSelector(selectTodoStats);

  return (
    <div>
      <p>完了率: {stats.percentComplete}%</p>
      {filteredTodos.map((todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </div>
  );
}

3. アーキテクチャと内部構造

3.1 単方向データフロー

Redux のデータフローは厳密に単方向である。この予測可能なパターンがデバッグと理解を容易にする。

┌─────────────────────────────────────────────────────┐
│                    Redux データフロー                   │
│                                                       │
│  ① UI でイベント発生                                    │
│       │                                               │
│       ▼                                               │
│  ② Action を Dispatch                                  │
│       │                                               │
│       ▼                                               │
│  ③ Middleware が Action を処理                          │
│       │(ログ記録、非同期処理、変換など)                    │
│       ▼                                               │
│  ④ Reducer が新しい State を計算                        │
│       │(純粋関数: state + action → newState)           │
│       ▼                                               │
│  ⑤ Store が新しい State を保存                          │
│       │                                               │
│       ▼                                               │
│  ⑥ UI が再レンダリング                                  │
│       │(subscribe / useSelector で検知)                │
│       └──────────── ① に戻る ────────────┘             │
└─────────────────────────────────────────────────────┘

具体的なコードで追ってみよう。

// ① UI でボタンクリック
function AddTodoButton() {
  const dispatch = useDispatch();

  const handleClick = () => {
    // ② Action を Dispatch
    dispatch(addTodo('新しいタスク'));
  };

  return <button onClick={handleClick}>追加</button>;
}

// ③ Middleware が処理(ここではログ記録)
const loggerMiddleware = (store) => (next) => (action) => {
  console.log('dispatching:', action);
  const result = next(action); // 次の middleware/reducer へ
  console.log('next state:', store.getState());
  return result;
};

// ④ Reducer が新しい State を計算
const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addTodo: (state, action) => {
      state.items.push(action.payload); // Immer による安全な変更
    },
  },
});

// ⑤⑥ Store 更新 → UI 再レンダリング
function TodoList() {
  // useSelector が Store の変更を監視し、自動的に再レンダリング
  const todos = useSelector((state: RootState) => state.todos.items);
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

3.2 createStore の内部実装

Redux のコアは驚くほど小さい。createStore の簡略化された内部実装を見てみよう。

// Redux createStore の簡略化された再実装
function createStore(reducer, preloadedState, enhancer) {
  // Enhancer が渡された場合、createStore 自体を拡張する
  if (typeof enhancer === 'function') {
    return enhancer(createStore)(reducer, preloadedState);
  }

  let currentState = preloadedState;     // 現在の状態
  let currentReducer = reducer;          // 現在の Reducer
  let currentListeners = [];             // 購読リスナーの配列
  let nextListeners = currentListeners;  // 次回の購読リスナー
  let isDispatching = false;             // dispatch 中フラグ

  // リスナー配列のコピーを作成(安全な変更のため)
  function ensureCanMutateNextListeners() {
    if (nextListeners === currentListeners) {
      nextListeners = currentListeners.slice();
    }
  }

  // 現在の状態を返す
  function getState() {
    if (isDispatching) {
      throw new Error('Reducer の実行中に getState() は呼べません');
    }
    return currentState;
  }

  // リスナーを登録する
  function subscribe(listener) {
    if (isDispatching) {
      throw new Error('Reducer の実行中に subscribe() は呼べません');
    }

    let isSubscribed = true;
    ensureCanMutateNextListeners();
    nextListeners.push(listener);

    // 購読解除関数を返す
    return function unsubscribe() {
      if (!isSubscribed) return;
      isSubscribed = false;
      ensureCanMutateNextListeners();
      const index = nextListeners.indexOf(listener);
      nextListeners.splice(index, 1);
      currentListeners = null; // GC のヒント
    };
  }

  // アクションを dispatch する
  function dispatch(action) {
    // アクションはプレーンオブジェクトでなければならない
    if (typeof action.type === 'undefined') {
      throw new Error('アクションには type プロパティが必要です');
    }

    if (isDispatching) {
      throw new Error('Reducer 内で dispatch() は呼べません');
    }

    try {
      isDispatching = true;
      // Reducer を実行して新しい状態を計算
      currentState = currentReducer(currentState, action);
    } finally {
      isDispatching = false;
    }

    // すべてのリスナーに通知
    const listeners = (currentListeners = nextListeners);
    for (let i = 0; i < listeners.length; i++) {
      listeners[i]();
    }

    return action;
  }

  // Reducer を動的に差し替える
  function replaceReducer(nextReducer) {
    currentReducer = nextReducer;
    dispatch({ type: '@@redux/REPLACE' });
  }

  // 初期状態を構築するための初期 dispatch
  dispatch({ type: '@@redux/INIT' });

  return {
    getState,
    dispatch,
    subscribe,
    replaceReducer,
  };
}

3.3 Middleware パイプライン

Middleware は dispatch と Reducer の間に挿入される拡張ポイントである。

// Middleware のシグネチャ(カリー化された3重の関数)
type Middleware = (store) => (next) => (action) => any;
//                 │          │          │
//                 │          │          └── dispatch されたアクション
//                 │          └── 次の middleware の dispatch(またはオリジナルの dispatch)
//                 └── { getState, dispatch } を持つストアAPI

// Middleware の連鎖の仕組み
// dispatch → middleware1 → middleware2 → middleware3 → reducer
//                ↓              ↓              ↓
//             next = middleware2  next = middleware3  next = originalDispatch

// applyMiddleware の簡略化された実装
function applyMiddleware(...middlewares) {
  return (createStore) => (reducer, preloadedState) => {
    const store = createStore(reducer, preloadedState);
    let dispatch = store.dispatch;

    // 各 middleware に渡す API
    const middlewareAPI = {
      getState: store.getState,
      dispatch: (action) => dispatch(action),
    };

    // 各 middleware を初期化(store API を渡す)
    const chain = middlewares.map((middleware) => middleware(middlewareAPI));

    // middleware を compose して新しい dispatch を作成
    dispatch = compose(...chain)(store.dispatch);

    return { ...store, dispatch };
  };
}

// compose 関数の実装
function compose(...funcs) {
  if (funcs.length === 0) return (arg) => arg;
  if (funcs.length === 1) return funcs[0];
  return funcs.reduce(
    (a, b) =>
      (...args) =>
        a(b(...args))
  );
}

3.4 Enhancer(エンハンサー)

Enhancer は Store 自体の機能を拡張するための高階関数である。applyMiddleware 自体がエンハンサーの一種である。

// Enhancer のシグネチャ
type StoreEnhancer = (createStore) => (reducer, preloadedState) => Store;

// カスタムエンハンサーの例:自動ログ保存
function persistEnhancer(createStore) {
  return (reducer, preloadedState) => {
    // localStorage から状態を復元
    const savedState = JSON.parse(
      localStorage.getItem('redux-state') || 'null'
    );
    const initialState = savedState || preloadedState;

    const store = createStore(reducer, initialState);

    // 状態変更時に自動保存
    store.subscribe(() => {
      localStorage.setItem(
        'redux-state',
        JSON.stringify(store.getState())
      );
    });

    return store;
  };
}

// 複数のエンハンサーを合成
import { compose } from '@reduxjs/toolkit';

const composedEnhancer = compose(
  applyMiddleware(thunkMiddleware, loggerMiddleware),
  persistEnhancer,
  // Redux DevTools(開発環境のみ)
  window.__REDUX_DEVTOOLS_EXTENSION__?.() ?? ((f) => f)
);

// configureStore では enhancers オプションで追加可能
const store = configureStore({
  reducer: rootReducer,
  enhancers: (getDefaultEnhancers) =>
    getDefaultEnhancers().concat(persistEnhancer),
});

3.5 Immer によるイミュータブル更新

Redux Toolkit は内部で Immer ライブラリを使用しており、ミュータブルな書き方でイミュータブルな更新を実現する。

// Immer なし(手動のイミュータブル更新)
function todosReducer(state, action) {
  switch (action.type) {
    case 'todos/addTodo':
      return {
        ...state,
        items: [...state.items, action.payload],
      };
    case 'todos/updateTodo':
      return {
        ...state,
        items: state.items.map((todo) =>
          todo.id === action.payload.id
            ? { ...todo, ...action.payload.changes }
            : todo
        ),
      };
    case 'todos/removeTag':
      return {
        ...state,
        items: state.items.map((todo) =>
          todo.id === action.payload.todoId
            ? {
                ...todo,
                tags: todo.tags.filter(
                  (tag) => tag !== action.payload.tagName
                ),
              }
            : todo
        ),
      };
    default:
      return state;
  }
}

// Immer あり(Redux Toolkit の createSlice 内部)
const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addTodo: (state, action) => {
      // push() を直接呼べる!(Immer が不変性を保証)
      state.items.push(action.payload);
    },
    updateTodo: (state, action) => {
      const todo = state.items.find((t) => t.id === action.payload.id);
      if (todo) {
        // 直接プロパティを書き換えられる
        Object.assign(todo, action.payload.changes);
      }
    },
    removeTag: (state, action) => {
      const todo = state.items.find((t) => t.id === action.payload.todoId);
      if (todo) {
        // ネストしたオブジェクトも直接操作可能
        const tagIndex = todo.tags.indexOf(action.payload.tagName);
        if (tagIndex !== -1) {
          todo.tags.splice(tagIndex, 1);
        }
      }
    },
  },
});

Immer の動作原理を理解しておくことは重要である。

import { produce } from 'immer';

// Immer は Proxy を使って変更を検知し、
// 新しいオブジェクトを自動生成する
const nextState = produce(currentState, (draft) => {
  // draft は currentState の「下書き」
  // draft への変更は追跡され、新しいオブジェクトに反映される
  draft.items.push({ id: 3, text: '新しいタスク' });
  draft.items[0].completed = true;
});

// currentState は変更されていない(不変性が保たれている)
console.log(currentState.items.length); // 2(元のまま)
console.log(nextState.items.length);    // 3(新しい状態)
console.log(currentState === nextState); // false(異なるオブジェクト)

// 変更されていない部分は参照が共有される(構造共有)
console.log(currentState.items[1] === nextState.items[1]); // true

4. Redux Toolkit (RTK)

Redux Toolkit(RTK)は、Redux の公式ツールセットであり、一般的な Redux のユースケースを簡素化するためのユーティリティを提供する。現在、Redux を使用するすべてのプロジェクトで RTK の利用が強く推奨されている。

4.1 configureStore

configureStore は、createStore のラッパーであり、良いデフォルト設定を提供する。

import { configureStore } from '@reduxjs/toolkit';
import todosReducer from '../features/todos/todosSlice';
import authReducer from '../features/auth/authSlice';
import notificationsReducer from '../features/notifications/notificationsSlice';

// 基本的な使用法
const store = configureStore({
  reducer: {
    todos: todosReducer,
    auth: authReducer,
    notifications: notificationsReducer,
  },
});

// configureStore が自動的に行うこと:
// 1. combineReducers でルート Reducer を構築
// 2. redux-thunk ミドルウェアを追加
// 3. 開発環境で不変性チェックミドルウェアを追加
// 4. 開発環境でシリアライズ可能性チェックミドルウェアを追加
// 5. Redux DevTools Extension を自動接続

// 型の定義(プロジェクト全体で使用する)
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export type AppStore = typeof store;

export default store;

高度な設定例も見てみよう。

import { configureStore, Tuple } from '@reduxjs/toolkit';
import { createLogger } from 'redux-logger';

const store = configureStore({
  reducer: {
    todos: todosReducer,
    auth: authReducer,
  },

  // ミドルウェアのカスタマイズ
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      // シリアライズ可能性チェックの設定
      serializableCheck: {
        // 特定のアクションをチェック対象外にする
        ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
        // 特定のパスをチェック対象外にする
        ignoredPaths: ['auth.token'],
      },
      // 不変性チェックの設定
      immutableCheck: {
        // 大きな状態ツリーでのパフォーマンス対策
        warnAfter: 128,
      },
      // Thunk ミドルウェアに追加引数を渡す
      thunk: {
        extraArgument: {
          apiClient: axios.create({ baseURL: '/api' }),
        },
      },
    }).concat(createLogger({ collapsed: true })),

  // 初期状態(サーバーサイドレンダリング用)
  preloadedState: {
    auth: { user: null, isAuthenticated: false, token: null },
  },

  // 開発環境での DevTools 設定
  devTools: process.env.NODE_ENV !== 'production' && {
    name: 'MyApp',
    maxAge: 50,           // 保持するアクション数
    trace: true,          // スタックトレースを記録
    traceLimit: 25,       // トレースのフレーム数
  },
});

4.2 createSlice

createSlice は Redux Toolkit の中核機能であり、Reducer、Action Creator、Action Type を一度に生成する。

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

// 型定義
interface Todo {
  id: string;
  text: string;
  completed: boolean;
  priority: 'low' | 'medium' | 'high';
  createdAt: string;
}

interface TodosState {
  items: Todo[];
  filter: 'all' | 'active' | 'completed';
  searchQuery: string;
  sortBy: 'date' | 'priority' | 'name';
}

const initialState: TodosState = {
  items: [],
  filter: 'all',
  searchQuery: '',
  sortBy: 'date',
};

// createSlice の定義
const todosSlice = createSlice({
  name: 'todos', // アクションタイプの prefix になる

  initialState,

  reducers: {
    // 基本的な Reducer
    addTodo: (state, action: PayloadAction<Omit<Todo, 'id' | 'createdAt'>>) => {
      state.items.push({
        ...action.payload,
        id: crypto.randomUUID(),
        createdAt: new Date().toISOString(),
      });
    },

    // Prepare callback を使った Reducer
    addTodoWithPrepare: {
      reducer: (state, action: PayloadAction<Todo>) => {
        state.items.push(action.payload);
      },
      // prepare でペイロードを加工する
      prepare: (text: string, priority: Todo['priority'] = 'medium') => ({
        payload: {
          id: crypto.randomUUID(),
          text,
          completed: false,
          priority,
          createdAt: new Date().toISOString(),
        },
      }),
    },

    toggleTodo: (state, action: PayloadAction<string>) => {
      const todo = state.items.find((t) => t.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },

    removeTodo: (state, action: PayloadAction<string>) => {
      const index = state.items.findIndex((t) => t.id === action.payload);
      if (index !== -1) {
        state.items.splice(index, 1);
      }
    },

    updateTodoPriority: (
      state,
      action: PayloadAction<{ id: string; priority: Todo['priority'] }>
    ) => {
      const todo = state.items.find((t) => t.id === action.payload.id);
      if (todo) {
        todo.priority = action.payload.priority;
      }
    },

    setFilter: (state, action: PayloadAction<TodosState['filter']>) => {
      state.filter = action.payload;
    },

    setSearchQuery: (state, action: PayloadAction<string>) => {
      state.searchQuery = action.payload;
    },

    setSortBy: (state, action: PayloadAction<TodosState['sortBy']>) => {
      state.sortBy = action.payload;
    },

    // 複数のタスクを一度に完了にする
    completeAll: (state) => {
      state.items.forEach((todo) => {
        todo.completed = true;
      });
    },

    // 完了済みタスクをクリア
    clearCompleted: (state) => {
      state.items = state.items.filter((todo) => !todo.completed);
    },

    // 全状態をリセット
    resetTodos: () => initialState,
  },
});

// 自動生成される Action Creator と Action Type をエクスポート
export const {
  addTodo,
  addTodoWithPrepare,
  toggleTodo,
  removeTodo,
  updateTodoPriority,
  setFilter,
  setSearchQuery,
  setSortBy,
  completeAll,
  clearCompleted,
  resetTodos,
} = todosSlice.actions;

// Reducer をエクスポート
export default todosSlice.reducer;

// 自動生成されるアクションタイプの例:
// todosSlice.actions.addTodo({ text: 'test', completed: false, priority: 'low' })
// → { type: 'todos/addTodo', payload: { text: 'test', ... } }

4.3 createAsyncThunk

createAsyncThunk は、非同期ロジックのための標準化されたパターンを提供する。

import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';

// 基本的な createAsyncThunk
export const fetchTodos = createAsyncThunk(
  'todos/fetchTodos', // アクションタイプの prefix
  async (_, { rejectWithValue }) => {
    try {
      const response = await fetch('/api/todos');
      if (!response.ok) {
        throw new Error('サーバーエラー');
      }
      const data: Todo[] = await response.json();
      return data;
    } catch (error) {
      // rejectWithValue でカスタムエラーを返す
      return rejectWithValue({
        message: error instanceof Error ? error.message : '不明なエラー',
        statusCode: 500,
      });
    }
  }
);

// パラメータ付き createAsyncThunk
export const fetchTodoById = createAsyncThunk(
  'todos/fetchById',
  async (todoId: string, { rejectWithValue }) => {
    try {
      const response = await fetch(`/api/todos/${todoId}`);
      if (!response.ok) throw new Error('Todo が見つかりません');
      return (await response.json()) as Todo;
    } catch (error) {
      return rejectWithValue({ message: (error as Error).message });
    }
  }
);

// 条件付き実行(condition)
export const fetchTodosIfNeeded = createAsyncThunk(
  'todos/fetchIfNeeded',
  async (_, { getState }) => {
    const response = await fetch('/api/todos');
    return (await response.json()) as Todo[];
  },
  {
    // condition が false を返すと thunk は実行されない
    condition: (_, { getState }) => {
      const state = getState() as RootState;
      const { loading, items } = state.todos;
      // 既にロード中、またはデータがある場合はスキップ
      if (loading || items.length > 0) {
        return false;
      }
    },
  }
);

// 複数の dispatch を行う createAsyncThunk
export const createTodoAndNotify = createAsyncThunk(
  'todos/createAndNotify',
  async (
    todoData: { text: string; priority: Todo['priority'] },
    { dispatch, rejectWithValue }
  ) => {
    try {
      const response = await fetch('/api/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(todoData),
      });
      const newTodo = await response.json();

      // 別のアクションを dispatch
      dispatch(
        showNotification({
          message: `タスク「${newTodo.text}」を作成しました`,
          type: 'success',
        })
      );

      return newTodo;
    } catch (error) {
      dispatch(
        showNotification({
          message: 'タスクの作成に失敗しました',
          type: 'error',
        })
      );
      return rejectWithValue({ message: (error as Error).message });
    }
  }
);

// Slice での extraReducers による処理
const todosSlice = createSlice({
  name: 'todos',
  initialState: {
    items: [] as Todo[],
    loading: false,
    error: null as string | null,
    currentRequestId: undefined as string | undefined,
  },
  reducers: {
    // 同期的な reducer...
  },
  extraReducers: (builder) => {
    builder
      // fetchTodos のライフサイクル
      .addCase(fetchTodos.pending, (state, action) => {
        state.loading = true;
        state.error = null;
        state.currentRequestId = action.meta.requestId;
      })
      .addCase(fetchTodos.fulfilled, (state, action) => {
        // 最新のリクエストのみ処理する(レースコンディション対策)
        if (state.currentRequestId === action.meta.requestId) {
          state.loading = false;
          state.items = action.payload;
          state.currentRequestId = undefined;
        }
      })
      .addCase(fetchTodos.rejected, (state, action) => {
        if (state.currentRequestId === action.meta.requestId) {
          state.loading = false;
          state.error = action.payload?.message ?? 'エラーが発生しました';
          state.currentRequestId = undefined;
        }
      })

      // fetchTodoById のライフサイクル
      .addCase(fetchTodoById.fulfilled, (state, action) => {
        const existingIndex = state.items.findIndex(
          (t) => t.id === action.payload.id
        );
        if (existingIndex !== -1) {
          state.items[existingIndex] = action.payload;
        } else {
          state.items.push(action.payload);
        }
      })

      // マッチャーを使った汎用的なエラーハンドリング
      .addMatcher(
        (action) => action.type.endsWith('/rejected'),
        (state, action) => {
          console.error('非同期操作が失敗:', action.type, action.payload);
        }
      );
  },
});

4.4 createEntityAdapter

createEntityAdapter は、正規化されたデータの CRUD 操作を簡素化するユーティリティである。

import {
  createSlice,
  createEntityAdapter,
  EntityState,
  PayloadAction,
} from '@reduxjs/toolkit';

// エンティティの型定義
interface Article {
  id: string;
  title: string;
  content: string;
  authorId: string;
  publishedAt: string;
  category: string;
}

// EntityAdapter の作成
const articlesAdapter = createEntityAdapter<Article>({
  // ID フィールドの指定(デフォルトは 'id')
  selectId: (article) => article.id,
  // ソート順の指定
  sortComparer: (a, b) => b.publishedAt.localeCompare(a.publishedAt),
});

// 初期状態の生成
// EntityState<Article> = { ids: string[], entities: Record<string, Article> }
interface ArticlesState extends EntityState<Article, string> {
  loading: boolean;
  error: string | null;
  selectedId: string | null;
}

const initialState: ArticlesState = articlesAdapter.getInitialState({
  loading: false,
  error: null,
  selectedId: null,
});

// Slice の作成
const articlesSlice = createSlice({
  name: 'articles',
  initialState,
  reducers: {
    // 単一エンティティの追加
    articleAdded: articlesAdapter.addOne,

    // 複数エンティティの追加
    articlesReceived: articlesAdapter.addMany,

    // エンティティの更新(部分更新)
    articleUpdated: articlesAdapter.updateOne,
    // 使用例: dispatch(articleUpdated({ id: '1', changes: { title: '新タイトル' } }))

    // エンティティの Upsert(存在すれば更新、なければ追加)
    articleUpserted: articlesAdapter.upsertOne,

    // エンティティの削除
    articleRemoved: articlesAdapter.removeOne,

    // 複数エンティティの削除
    articlesRemoved: articlesAdapter.removeMany,

    // 全エンティティの削除
    allArticlesRemoved: articlesAdapter.removeAll,

    // 全エンティティの置き換え
    articlesReplaced: articlesAdapter.setAll,

    // 選択中の記事を設定
    selectArticle: (state, action: PayloadAction<string | null>) => {
      state.selectedId = action.payload;
    },
  },
  extraReducers: (builder) => {
    builder.addCase(fetchArticles.fulfilled, (state, action) => {
      articlesAdapter.setAll(state, action.payload);
      state.loading = false;
    });
  },
});

// セレクターの自動生成
export const {
  selectAll: selectAllArticles,        // すべての記事を配列で取得
  selectById: selectArticleById,       // ID で1件取得
  selectIds: selectArticleIds,         // ID の配列を取得
  selectEntities: selectArticleEntities, // エンティティの辞書を取得
  selectTotal: selectArticleTotal,     // 記事の総数を取得
} = articlesAdapter.getSelectors(
  (state: RootState) => state.articles
);

// 正規化された状態の構造
// {
//   ids: ['3', '1', '2'],  // ソート順で管理
//   entities: {
//     '1': { id: '1', title: '記事1', ... },
//     '2': { id: '2', title: '記事2', ... },
//     '3': { id: '3', title: '記事3', ... },
//   },
//   loading: false,
//   error: null,
//   selectedId: null,
// }

4.5 RTK Query の概要

RTK Query は、データフェッチングとキャッシングのための強力なツールセットである(詳細は次章で解説)。

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

// API サービスの定義
export const apiSlice = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Todo', 'User'],
  endpoints: (builder) => ({
    // Query エンドポイント(GET)
    getTodos: builder.query<Todo[], void>({
      query: () => '/todos',
      providesTags: ['Todo'],
    }),
    // Mutation エンドポイント(POST/PUT/DELETE)
    addTodo: builder.mutation<Todo, Partial<Todo>>({
      query: (newTodo) => ({
        url: '/todos',
        method: 'POST',
        body: newTodo,
      }),
      invalidatesTags: ['Todo'],
    }),
  }),
});

// 自動生成されるフック
export const { useGetTodosQuery, useAddTodoMutation } = apiSlice;

5. RTK Query 詳解

RTK Query は Redux Toolkit に組み込まれたデータフェッチング・キャッシングソリューションである。React Query や SWR と同等の機能を提供しながら、Redux エコシステムとシームレスに統合される。

5.1 createApi の詳細

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import type { RootState } from '../store';

// 型定義
interface Todo {
  id: string;
  text: string;
  completed: boolean;
  userId: string;
}

interface User {
  id: string;
  name: string;
  email: string;
}

interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
}

// API の定義
export const apiSlice = createApi({
  // Store 内の Reducer のキー名
  reducerPath: 'api',

  // ベースクエリの設定
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://api.example.com/v1',

    // リクエストヘッダーの設定
    prepareHeaders: (headers, { getState }) => {
      const token = (getState() as RootState).auth.token;
      if (token) {
        headers.set('Authorization', `Bearer ${token}`);
      }
      headers.set('Accept-Language', 'ja');
      return headers;
    },

    // タイムアウト設定
    timeout: 10000,
  }),

  // キャッシュの有効期限(秒)
  keepUnusedDataFor: 60,

  // 再フェッチの設定
  refetchOnFocus: true,       // ウィンドウフォーカス時
  refetchOnReconnect: true,   // ネットワーク再接続時
  refetchOnMountOrArgChange: false,

  // タグの種類(キャッシュ無効化に使用)
  tagTypes: ['Todo', 'User', 'Notification'],

  // エンドポイントの定義
  endpoints: (builder) => ({
    // === Query エンドポイント(データの取得) ===

    // 全 Todo を取得
    getTodos: builder.query<Todo[], void>({
      query: () => '/todos',
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Todo' as const, id })),
              { type: 'Todo', id: 'LIST' },
            ]
          : [{ type: 'Todo', id: 'LIST' }],
    }),

    // ページネーション付き取得
    getTodosPaginated: builder.query<
      PaginatedResponse<Todo>,
      { page: number; pageSize: number; filter?: string }
    >({
      query: ({ page, pageSize, filter }) => ({
        url: '/todos',
        params: { page, pageSize, filter },
      }),
      providesTags: (result) =>
        result
          ? [
              ...result.data.map(({ id }) => ({
                type: 'Todo' as const,
                id,
              })),
              { type: 'Todo', id: 'PARTIAL-LIST' },
            ]
          : [{ type: 'Todo', id: 'PARTIAL-LIST' }],
    }),

    // 単一 Todo を取得
    getTodoById: builder.query<Todo, string>({
      query: (id) => `/todos/${id}`,
      providesTags: (result, error, id) => [{ type: 'Todo', id }],
    }),

    // ユーザー情報を取得
    getUser: builder.query<User, string>({
      query: (userId) => `/users/${userId}`,
      providesTags: (result, error, id) => [{ type: 'User', id }],
    }),

    // レスポンスの変換
    getTodosByUser: builder.query<Todo[], string>({
      query: (userId) => `/users/${userId}/todos`,
      // レスポンスを変換(完了済みのみフィルタなど)
      transformResponse: (response: Todo[]) =>
        response.sort((a, b) => a.text.localeCompare(b.text)),
      // エラーレスポンスの変換
      transformErrorResponse: (response) => ({
        status: response.status,
        message:
          typeof response.data === 'object' && response.data !== null
            ? (response.data as { message: string }).message
            : '不明なエラー',
      }),
      providesTags: (result, error, userId) => [
        { type: 'Todo', id: `USER-${userId}` },
      ],
    }),

    // === Mutation エンドポイント(データの変更) ===

    // Todo を追加
    addTodo: builder.mutation<Todo, Omit<Todo, 'id'>>({
      query: (newTodo) => ({
        url: '/todos',
        method: 'POST',
        body: newTodo,
      }),
      // キャッシュを無効化してリストを再取得
      invalidatesTags: [{ type: 'Todo', id: 'LIST' }],
    }),

    // Todo を更新
    updateTodo: builder.mutation<Todo, { id: string; changes: Partial<Todo> }>({
      query: ({ id, changes }) => ({
        url: `/todos/${id}`,
        method: 'PATCH',
        body: changes,
      }),
      // 更新した Todo とリストのキャッシュを無効化
      invalidatesTags: (result, error, { id }) => [
        { type: 'Todo', id },
        { type: 'Todo', id: 'LIST' },
      ],
    }),

    // Todo を削除
    deleteTodo: builder.mutation<void, string>({
      query: (id) => ({
        url: `/todos/${id}`,
        method: 'DELETE',
      }),
      invalidatesTags: (result, error, id) => [
        { type: 'Todo', id },
        { type: 'Todo', id: 'LIST' },
      ],
    }),
  }),
});

// 自動生成されるフックをエクスポート
export const {
  useGetTodosQuery,
  useGetTodosPaginatedQuery,
  useGetTodoByIdQuery,
  useGetUserQuery,
  useGetTodosByUserQuery,
  useAddTodoMutation,
  useUpdateTodoMutation,
  useDeleteTodoMutation,
} = apiSlice;

5.2 Store への統合

import { configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
import { apiSlice } from './services/api';
import todosReducer from './features/todos/todosSlice';

const store = configureStore({
  reducer: {
    todos: todosReducer,
    // API スライスの Reducer を追加
    [apiSlice.reducerPath]: apiSlice.reducer,
  },
  // API スライスのミドルウェアを追加(キャッシュ管理、ポーリング等に必要)
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(apiSlice.middleware),
});

// refetchOnFocus / refetchOnReconnect を有効化するためのリスナー設定
setupListeners(store.dispatch);

export default store;

5.3 Query フックの使用

import { useGetTodosQuery, useGetTodoByIdQuery } from '../services/api';

function TodoList() {
  // 基本的な Query
  const {
    data: todos,         // レスポンスデータ
    error,               // エラー情報
    isLoading,           // 初回ロード中
    isFetching,          // 再取得中(初回含む)
    isSuccess,           // 成功
    isError,             // エラー
    isUninitialized,     // まだ実行されていない
    refetch,             // 手動で再取得する関数
    currentData,         // 現在のキャッシュデータ(再取得中は前のデータ)
  } = useGetTodosQuery();

  // ローディング状態のハンドリング
  if (isLoading) {
    return <LoadingSkeleton />;
  }

  if (isError) {
    return (
      <ErrorMessage
        error={error}
        onRetry={refetch}
      />
    );
  }

  return (
    <div>
      {/* 再取得中のインジケーター */}
      {isFetching && <RefetchingBanner />}

      <ul>
        {todos?.map((todo) => (
          <TodoItem key={todo.id} id={todo.id} />
        ))}
      </ul>

      <button onClick={refetch}>更新</button>
    </div>
  );
}

// パラメータ付き Query
function TodoDetail({ todoId }: { todoId: string }) {
  const { data: todo, isLoading, isError } = useGetTodoByIdQuery(todoId);

  if (isLoading) return <p>読み込み中...</p>;
  if (isError || !todo) return <p>エラーが発生しました</p>;

  return (
    <div>
      <h2>{todo.text}</h2>
      <p>状態: {todo.completed ? '完了' : '未完了'}</p>
    </div>
  );
}

// 条件付き Query(skip オプション)
function UserTodos({ userId }: { userId: string | null }) {
  const { data: todos } = useGetTodosByUserQuery(userId!, {
    // userId が null の場合はクエリを実行しない
    skip: !userId,
  });

  return <div>{/* ... */}</div>;
}

// ポーリング
function LiveDashboard() {
  const { data: todos } = useGetTodosQuery(undefined, {
    // 30秒ごとに自動再取得
    pollingInterval: 30000,
    // ウィンドウが非フォーカス時はポーリングを停止
    skipPollingIfUnfocused: true,
  });

  return <Dashboard data={todos} />;
}

5.4 Mutation フックの使用

import {
  useAddTodoMutation,
  useUpdateTodoMutation,
  useDeleteTodoMutation,
} from '../services/api';

function TodoForm() {
  const [text, setText] = useState('');

  // Mutation フック
  const [addTodo, {
    isLoading: isAdding,
    isSuccess,
    isError,
    error,
    reset,      // Mutation の状態をリセット
  }] = useAddTodoMutation();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      // await で完了を待つ(.unwrap() でエラーを throw にする)
      await addTodo({
        text,
        completed: false,
        userId: 'current-user',
      }).unwrap();

      setText('');
      toast.success('タスクを追加しました');
    } catch (err) {
      toast.error('タスクの追加に失敗しました');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        disabled={isAdding}
      />
      <button type="submit" disabled={isAdding}>
        {isAdding ? '追加中...' : '追加'}
      </button>
    </form>
  );
}

function TodoItem({ todo }: { todo: Todo }) {
  const [updateTodo] = useUpdateTodoMutation();
  const [deleteTodo, { isLoading: isDeleting }] = useDeleteTodoMutation();

  const handleToggle = async () => {
    await updateTodo({
      id: todo.id,
      changes: { completed: !todo.completed },
    }).unwrap();
  };

  const handleDelete = async () => {
    if (window.confirm('削除しますか?')) {
      await deleteTodo(todo.id).unwrap();
    }
  };

  return (
    <li>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={handleToggle}
      />
      <span>{todo.text}</span>
      <button onClick={handleDelete} disabled={isDeleting}>
        {isDeleting ? '削除中...' : '削除'}
      </button>
    </li>
  );
}

5.5 楽観的更新(Optimistic Updates)

const apiSlice = createApi({
  // ...
  endpoints: (builder) => ({
    updateTodo: builder.mutation<Todo, { id: string; changes: Partial<Todo> }>({
      query: ({ id, changes }) => ({
        url: `/todos/${id}`,
        method: 'PATCH',
        body: changes,
      }),

      // 楽観的更新の実装
      async onQueryStarted({ id, changes }, { dispatch, queryFulfilled }) {
        // 1. API レスポンスを待たずにキャッシュを即座に更新
        const patchResult = dispatch(
          apiSlice.util.updateQueryData('getTodos', undefined, (draft) => {
            const todo = draft.find((t) => t.id === id);
            if (todo) {
              Object.assign(todo, changes);
            }
          })
        );

        try {
          // 2. API レスポンスを待つ
          await queryFulfilled;
        } catch {
          // 3. エラーの場合、キャッシュの変更をロールバック
          patchResult.undo();
        }
      },
    }),

    deleteTodo: builder.mutation<void, string>({
      query: (id) => ({
        url: `/todos/${id}`,
        method: 'DELETE',
      }),

      async onQueryStarted(id, { dispatch, queryFulfilled }) {
        // リストからの楽観的削除
        const patchResult = dispatch(
          apiSlice.util.updateQueryData('getTodos', undefined, (draft) => {
            const index = draft.findIndex((t) => t.id === id);
            if (index !== -1) {
              draft.splice(index, 1);
            }
          })
        );

        try {
          await queryFulfilled;
        } catch {
          patchResult.undo();
          // ユーザーに通知
          dispatch(showNotification({
            message: '削除に失敗しました。元に戻しました。',
            type: 'error',
          }));
        }
      },
    }),

    // 新規追加の楽観的更新
    addTodo: builder.mutation<Todo, Omit<Todo, 'id'>>({
      query: (newTodo) => ({
        url: '/todos',
        method: 'POST',
        body: newTodo,
      }),

      async onQueryStarted(newTodo, { dispatch, queryFulfilled }) {
        // 仮の ID でキャッシュに追加
        const tempId = `temp-${Date.now()}`;
        const patchResult = dispatch(
          apiSlice.util.updateQueryData('getTodos', undefined, (draft) => {
            draft.push({ ...newTodo, id: tempId });
          })
        );

        try {
          const { data: savedTodo } = await queryFulfilled;
          // サーバーからの応答で仮 ID を実際の ID に置換
          dispatch(
            apiSlice.util.updateQueryData('getTodos', undefined, (draft) => {
              const index = draft.findIndex((t) => t.id === tempId);
              if (index !== -1) {
                draft[index] = savedTodo;
              }
            })
          );
        } catch {
          patchResult.undo();
        }
      },
    }),
  }),
});

5.6 キャッシュタグとインバリデーション

// タグの仕組みの詳細
const apiSlice = createApi({
  tagTypes: ['Todo', 'User'],
  endpoints: (builder) => ({
    // providesTags: このクエリが提供するタグ
    getTodos: builder.query<Todo[], void>({
      query: () => '/todos',
      providesTags: (result) =>
        result
          ? [
              // 各 Todo に個別のタグを付与
              ...result.map(({ id }) => ({
                type: 'Todo' as const,
                id,
              })),
              // リスト全体のタグ
              { type: 'Todo', id: 'LIST' },
            ]
          : [{ type: 'Todo', id: 'LIST' }],
    }),

    // invalidatesTags: この Mutation が無効化するタグ
    addTodo: builder.mutation<Todo, Omit<Todo, 'id'>>({
      query: (body) => ({ url: '/todos', method: 'POST', body }),
      // LIST タグを無効化 → getTodos が再取得される
      invalidatesTags: [{ type: 'Todo', id: 'LIST' }],
    }),

    updateTodo: builder.mutation<Todo, { id: string; changes: Partial<Todo> }>({
      query: ({ id, changes }) => ({
        url: `/todos/${id}`,
        method: 'PATCH',
        body: changes,
      }),
      // 更新した Todo のタグとリストタグを無効化
      invalidatesTags: (result, error, { id }) => [
        { type: 'Todo', id },
        { type: 'Todo', id: 'LIST' },
      ],
    }),

    deleteTodo: builder.mutation<void, string>({
      query: (id) => ({ url: `/todos/${id}`, method: 'DELETE' }),
      invalidatesTags: (result, error, id) => [
        { type: 'Todo', id },
        { type: 'Todo', id: 'LIST' },
      ],
    }),
  }),
});

タグの無効化フローを図示すると以下のようになる。

キャッシュタグのフロー:

getTodos() → キャッシュに保存 → タグ付与: [Todo:1, Todo:2, Todo:3, Todo:LIST]
                                          │
updateTodo({id: '2', ...}) ─────────────────┤
                                          │
invalidatesTags: [Todo:2, Todo:LIST] ←─────┘
        │
        ▼
getTodos() が自動的に再取得される
(Todo:LIST タグが無効化されたため)

6. ミドルウェア

ミドルウェアは Redux の dispatch パイプラインに挿入される拡張ポイントであり、アクションが Reducer に到達する前にインターセプトし、変換、遅延、置換、または副作用の実行を可能にする。

6.1 redux-thunk

Thunk は最も一般的なミドルウェアであり、Redux Toolkit に標準で組み込まれている。アクションオブジェクトの代わりに関数を dispatch できるようにする。

// Thunk ミドルウェアの内部実装(非常にシンプル)
function thunkMiddleware({ dispatch, getState }) {
  return (next) => (action) => {
    // アクションが関数の場合、dispatch と getState を渡して実行
    if (typeof action === 'function') {
      return action(dispatch, getState);
    }
    // それ以外は次のミドルウェアへ
    return next(action);
  };
}

// Thunk の使用例

// 基本的な非同期 Thunk
function fetchUserProfile(userId: string) {
  return async (dispatch: AppDispatch, getState: () => RootState) => {
    // 状態を確認してから API を呼ぶ
    const { auth } = getState();
    if (!auth.isAuthenticated) {
      dispatch(showError('ログインが必要です'));
      return;
    }

    dispatch(setLoading(true));
    try {
      const response = await fetch(`/api/users/${userId}`, {
        headers: { Authorization: `Bearer ${auth.token}` },
      });
      const profile = await response.json();
      dispatch(setUserProfile(profile));
    } catch (error) {
      dispatch(setError('プロフィールの取得に失敗しました'));
    } finally {
      dispatch(setLoading(false));
    }
  };
}

// extraArgument を利用した Thunk
// configureStore で設定:
// middleware: (getDefault) => getDefault({
//   thunk: { extraArgument: { apiClient, analytics } }
// })

function trackEvent(eventName: string, properties: Record<string, unknown>) {
  return (
    dispatch: AppDispatch,
    getState: () => RootState,
    { analytics }: { analytics: AnalyticsClient }
  ) => {
    const { auth } = getState();
    analytics.track(eventName, {
      ...properties,
      userId: auth.user?.id,
    });
  };
}

// 複数のアクションを順次 dispatch する Thunk
function checkout(cartItems: CartItem[]) {
  return async (dispatch: AppDispatch) => {
    dispatch(setCheckoutStatus('processing'));

    try {
      // 1. 在庫確認
      const stockCheck = await fetch('/api/stock/check', {
        method: 'POST',
        body: JSON.stringify(cartItems),
      });
      const stockResult = await stockCheck.json();

      if (!stockResult.allInStock) {
        dispatch(setCheckoutStatus('out_of_stock'));
        dispatch(showNotification('一部の商品が在庫切れです'));
        return;
      }

      // 2. 注文作成
      const order = await fetch('/api/orders', {
        method: 'POST',
        body: JSON.stringify({ items: cartItems }),
      });
      const orderData = await order.json();
      dispatch(setOrderData(orderData));

      // 3. カートをクリア
      dispatch(clearCart());

      // 4. 完了通知
      dispatch(setCheckoutStatus('completed'));
      dispatch(showNotification('注文が完了しました!'));
    } catch (error) {
      dispatch(setCheckoutStatus('error'));
      dispatch(showNotification('注文処理中にエラーが発生しました'));
    }
  };
}

6.2 redux-saga

Redux Saga はジェネレーター関数を使用した副作用管理ライブラリである。複雑な非同期フローに適している。

import {
  call,
  put,
  takeEvery,
  takeLatest,
  all,
  fork,
  select,
  delay,
  race,
  take,
  cancel,
  cancelled,
} from 'redux-saga/effects';

// 基本的な Saga
function* fetchTodosSaga() {
  try {
    yield put(setLoading(true));
    const response: Response = yield call(fetch, '/api/todos');
    const todos: Todo[] = yield call([response, 'json']);
    yield put(setTodos(todos));
  } catch (error) {
    yield put(setError('タスクの取得に失敗しました'));
  } finally {
    yield put(setLoading(false));
  }
}

// takeLatest: 最後の dispatch のみ処理(前のリクエストをキャンセル)
function* watchFetchTodos() {
  yield takeLatest('todos/fetchRequested', fetchTodosSaga);
}

// デバウンス検索の実装
function* searchTodosSaga(action: PayloadAction<string>) {
  // 300ms 待つ(デバウンス)
  yield delay(300);

  try {
    const response: Response = yield call(
      fetch,
      `/api/todos/search?q=${encodeURIComponent(action.payload)}`
    );
    const results: Todo[] = yield call([response, 'json']);
    yield put(setSearchResults(results));
  } catch (error) {
    yield put(setSearchError('検索に失敗しました'));
  }
}

function* watchSearch() {
  yield takeLatest('todos/searchRequested', searchTodosSaga);
}

// レースコンディションの処理
function* fetchWithTimeout() {
  const { response, timeout } = yield race({
    response: call(fetch, '/api/slow-endpoint'),
    timeout: delay(5000), // 5秒タイムアウト
  });

  if (timeout) {
    yield put(setError('リクエストがタイムアウトしました'));
  } else {
    const data = yield call([response, 'json']);
    yield put(setData(data));
  }
}

// チャンネルを使ったポーリング
function* pollTodosSaga() {
  while (true) {
    try {
      const response: Response = yield call(fetch, '/api/todos');
      const todos: Todo[] = yield call([response, 'json']);
      yield put(setTodos(todos));
    } catch (error) {
      yield put(setError('ポーリング中にエラー'));
    }
    yield delay(10000); // 10秒間隔
  }
}

// ポーリングの開始/停止制御
function* watchPolling() {
  while (true) {
    yield take('todos/startPolling');
    const pollTask = yield fork(pollTodosSaga);

    yield take('todos/stopPolling');
    yield cancel(pollTask);
  }
}

// 複雑なビジネスロジック:注文プロセス
function* orderFlowSaga(action: PayloadAction<OrderRequest>) {
  try {
    // ステップ1: 在庫確認
    yield put(updateOrderStep('checking_stock'));
    const stockResult = yield call(checkStock, action.payload.items);

    if (!stockResult.available) {
      yield put(orderFailed('在庫不足'));
      return;
    }

    // ステップ2: 支払い処理
    yield put(updateOrderStep('processing_payment'));
    const paymentResult = yield call(processPayment, action.payload.payment);

    if (!paymentResult.success) {
      yield put(orderFailed('支払い処理失敗'));
      return;
    }

    // ステップ3: 注文確定
    yield put(updateOrderStep('confirming'));
    const order = yield call(createOrder, {
      items: action.payload.items,
      paymentId: paymentResult.id,
    });

    yield put(orderCompleted(order));
    yield put(clearCart());
    yield put(showNotification({ message: '注文完了!', type: 'success' }));
  } catch (error) {
    yield put(orderFailed('注文処理中にエラーが発生しました'));
  }
}

// ルート Saga
function* rootSaga() {
  yield all([
    fork(watchFetchTodos),
    fork(watchSearch),
    fork(watchPolling),
    takeEvery('order/submit', orderFlowSaga),
  ]);
}

export default rootSaga;

6.3 redux-observable

Redux Observable は RxJS を使用したミドルウェアで、リアクティブプログラミングのパワーを Redux に持ち込む。

import { ofType, Epic } from 'redux-observable';
import {
  switchMap,
  map,
  catchError,
  debounceTime,
  mergeMap,
  takeUntil,
  retry,
  retryWhen,
  delay as rxDelay,
} from 'rxjs/operators';
import { ajax } from 'rxjs/ajax';
import { of, timer, EMPTY } from 'rxjs';

type AppEpic = Epic<AnyAction, AnyAction, RootState>;

// 基本的な Epic
const fetchTodosEpic: AppEpic = (action$) =>
  action$.pipe(
    ofType('todos/fetchRequested'),
    switchMap(() =>
      ajax.getJSON<Todo[]>('/api/todos').pipe(
        map((todos) => ({ type: 'todos/fetchFulfilled', payload: todos })),
        catchError((error) =>
          of({
            type: 'todos/fetchRejected',
            payload: error.message,
          })
        )
      )
    )
  );

// デバウンス検索
const searchEpic: AppEpic = (action$) =>
  action$.pipe(
    ofType('search/queryChanged'),
    debounceTime(300),
    switchMap((action) =>
      ajax
        .getJSON<Todo[]>(
          `/api/todos/search?q=${encodeURIComponent(action.payload)}`
        )
        .pipe(
          map((results) => ({
            type: 'search/resultsFetched',
            payload: results,
          })),
          catchError((error) =>
            of({ type: 'search/error', payload: error.message })
          )
        )
    )
  );

// 自動リトライ
const fetchWithRetryEpic: AppEpic = (action$) =>
  action$.pipe(
    ofType('data/fetchRequested'),
    switchMap(() =>
      ajax.getJSON('/api/data').pipe(
        map((data) => ({ type: 'data/fetchFulfilled', payload: data })),
        retry({ count: 3, delay: 1000 }),
        catchError((error) =>
          of({ type: 'data/fetchRejected', payload: error.message })
        )
      )
    )
  );

// WebSocket 接続
const websocketEpic: AppEpic = (action$) =>
  action$.pipe(
    ofType('ws/connect'),
    switchMap((action) => {
      const ws = new WebSocket(action.payload.url);
      return new Observable((subscriber) => {
        ws.onmessage = (event) => {
          const data = JSON.parse(event.data);
          subscriber.next({
            type: 'ws/messageReceived',
            payload: data,
          });
        };
        ws.onerror = (error) => {
          subscriber.next({ type: 'ws/error', payload: 'WebSocket エラー' });
        };
        return () => ws.close();
      }).pipe(takeUntil(action$.pipe(ofType('ws/disconnect'))));
    })
  );

// ルート Epic
import { combineEpics } from 'redux-observable';
const rootEpic = combineEpics(
  fetchTodosEpic,
  searchEpic,
  fetchWithRetryEpic,
  websocketEpic
);

6.4 カスタムミドルウェア

// ログ記録ミドルウェア
const loggerMiddleware: Middleware<{}, RootState> =
  (store) => (next) => (action) => {
    console.group(action.type);
    console.log('dispatching:', action);
    console.log('前の状態:', store.getState());

    const result = next(action);

    console.log('次の状態:', store.getState());
    console.groupEnd();
    return result;
  };

// エラー報告ミドルウェア
const errorReportingMiddleware: Middleware =
  (store) => (next) => (action) => {
    try {
      return next(action);
    } catch (error) {
      console.error('Reducer でエラー発生:', error);
      // エラー報告サービスに送信
      Sentry.captureException(error, {
        extra: {
          action,
          state: store.getState(),
        },
      });
      throw error;
    }
  };

// 分析(アナリティクス)ミドルウェア
const analyticsMiddleware: Middleware<{}, RootState> =
  (store) => (next) => (action) => {
    // 特定のアクションを分析サービスに送信
    const trackableActions: Record<string, string> = {
      'todos/addTodo': 'Todo Created',
      'todos/toggleTodo': 'Todo Toggled',
      'auth/login/fulfilled': 'User Logged In',
      'auth/logout': 'User Logged Out',
    };

    const eventName = trackableActions[action.type];
    if (eventName) {
      analytics.track(eventName, {
        payload: action.payload,
        userId: store.getState().auth.user?.id,
      });
    }

    return next(action);
  };

// API リクエストミドルウェア(汎用)
const apiMiddleware: Middleware =
  (store) => (next) => async (action) => {
    if (action.type !== 'API_CALL') {
      return next(action);
    }

    const { url, method, body, onSuccess, onFailure, headers } =
      action.payload;

    next({ type: `${onSuccess}/pending` });

    try {
      const response = await fetch(url, {
        method,
        headers: {
          'Content-Type': 'application/json',
          ...headers,
        },
        body: body ? JSON.stringify(body) : undefined,
      });

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

      const data = await response.json();
      store.dispatch({ type: onSuccess, payload: data });
    } catch (error) {
      store.dispatch({
        type: onFailure,
        payload: (error as Error).message,
      });
    }
  };

// configureStore でのミドルウェア設定
const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware()
      .prepend(errorReportingMiddleware)     // 最初に実行
      .concat(analyticsMiddleware)           // 最後に実行
      .concat(loggerMiddleware),             // デバッグ用
});

6.5 ミドルウェアの比較

特性redux-thunkredux-sagaredux-observable
学習コスト低い高い非常に高い
ファイルサイズ~2KB~25KBRxJS依存(~30KB)
非同期処理Promise/async-awaitジェネレーターObservable
テスト容易性普通高い高い
キャンセル手動組み込み組み込み
デバウンス/スロットル手動組み込み組み込み
複雑なフロー難しい得意得意
RTK 統合標準搭載追加設定追加設定
推奨度★★★★★★★★☆☆★★☆☆☆

結論: 大多数のアプリケーションでは、redux-thunk(createAsyncThunk)と RTK Query の組み合わせで十分である。redux-saga は複雑な非同期ワークフロー(決済処理、リアルタイム同期など)が必要な場合にのみ検討する。


7. React-Redux 統合

React-Redux は、Redux ストアを React コンポーネントツリーに接続するための公式バインディングライブラリである。

7.1 Provider の設定

// app/providers.tsx(Next.js App Router の場合)
'use client';

import { Provider } from 'react-redux';
import { useRef } from 'react';
import { makeStore, AppStore } from '../lib/store';

export default function StoreProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  // Store のインスタンスをリクエストごとに作成(SSR対応)
  const storeRef = useRef<AppStore | null>(null);
  if (!storeRef.current) {
    storeRef.current = makeStore();
  }

  return <Provider store={storeRef.current}>{children}</Provider>;
}

// app/layout.tsx
import StoreProvider from './providers';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        <StoreProvider>{children}</StoreProvider>
      </body>
    </html>
  );
}

// lib/store.ts
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from '../features/todos/todosSlice';

export const makeStore = () => {
  return configureStore({
    reducer: {
      todos: todosReducer,
    },
  });
};

export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore['getState']>;
export type AppDispatch = AppStore['dispatch'];

7.2 型付きフックの作成

// lib/hooks.ts
import { useDispatch, useSelector, useStore } from 'react-redux';
import type { AppDispatch, RootState, AppStore } from './store';

// 型付きフック(プロジェクト全体でこれらを使う)
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppStore = useStore.withTypes<AppStore>();

// 使用例
function TodoList() {
  // RootState 型が自動推論される
  const todos = useAppSelector((state) => state.todos.items);
  const dispatch = useAppDispatch();

  const handleAdd = () => {
    // AppDispatch 型により、thunk も dispatch 可能
    dispatch(fetchTodos());
  };

  return (/* ... */);
}

7.3 useSelector の詳細

import { useAppSelector } from '../lib/hooks';
import { createSelector } from '@reduxjs/toolkit';

// 基本的な useSelector
function TodoCounter() {
  // State から値を抽出
  const count = useAppSelector((state) => state.todos.items.length);
  return <span>タスク数: {count}</span>;
}

// 注意: useSelector は参照の等値性で再レンダリングを判定する
function BadExample() {
  // 毎回新しい配列を生成するため、毎レンダリングで再レンダリングが発生
  const activeTodos = useAppSelector((state) =>
    state.todos.items.filter((t) => !t.completed) // 新しい配列参照
  );
  return <TodoList todos={activeTodos} />;
}

// 解決策1: createSelector でメモ化
const selectActiveTodos = createSelector(
  [(state: RootState) => state.todos.items],
  (items) => items.filter((t) => !t.completed)
);

function GoodExample() {
  // items が変わらない限り、同じ配列参照が返される
  const activeTodos = useAppSelector(selectActiveTodos);
  return <TodoList todos={activeTodos} />;
}

// 解決策2: shallowEqual を使用
import { shallowEqual } from 'react-redux';

function ShallowEqualExample() {
  // 浅い比較で再レンダリングを抑制
  const { total, completed } = useAppSelector(
    (state) => ({
      total: state.todos.items.length,
      completed: state.todos.items.filter((t) => t.completed).length,
    }),
    shallowEqual
  );

  return (
    <p>
      {completed}/{total} 完了
    </p>
  );
}

// 複数の値を個別の useSelector で取得する(推奨パターン)
function RecommendedPattern() {
  // 各 useSelector は独立して再レンダリングを判定
  const items = useAppSelector((state) => state.todos.items);
  const filter = useAppSelector((state) => state.todos.filter);
  const loading = useAppSelector((state) => state.todos.loading);

  // items, filter, loading のいずれかが変わった時のみ再レンダリング
  return (/* ... */);
}

7.4 useDispatch の詳細

import { useAppDispatch } from '../lib/hooks';

function TodoActions() {
  const dispatch = useAppDispatch();

  // 同期アクション
  const handleClearCompleted = () => {
    dispatch(clearCompleted());
  };

  // 非同期アクション(createAsyncThunk)
  const handleRefresh = async () => {
    try {
      // unwrap() でエラーを throw にする
      const todos = await dispatch(fetchTodos()).unwrap();
      console.log('取得成功:', todos.length, '件');
    } catch (error) {
      console.error('取得失敗:', error);
    }
  };

  // Thunk の dispatch
  const handleBulkUpdate = () => {
    dispatch((dispatch, getState) => {
      const state = getState();
      const activeTodos = state.todos.items.filter((t) => !t.completed);
      activeTodos.forEach((todo) => {
        dispatch(toggleTodo(todo.id));
      });
    });
  };

  return (
    <div>
      <button onClick={handleClearCompleted}>完了済みを削除</button>
      <button onClick={handleRefresh}>更新</button>
      <button onClick={handleBulkUpdate}>すべて完了にする</button>
    </div>
  );
}

7.5 connect(レガシー)

connect は React-Redux の旧 API であり、現在は Hooks API が推奨されている。レガシーコードベースでの理解のために記載する。

import { connect, ConnectedProps } from 'react-redux';

// mapStateToProps: Store の状態を Props にマッピング
const mapStateToProps = (state: RootState) => ({
  todos: state.todos.items,
  filter: state.todos.filter,
  loading: state.todos.loading,
});

// mapDispatchToProps: dispatch をラップした関数を Props にマッピング
const mapDispatchToProps = {
  addTodo,
  toggleTodo,
  removeTodo,
  setFilter,
  fetchTodos,
};

// ConnectedProps で型を推論
const connector = connect(mapStateToProps, mapDispatchToProps);
type PropsFromRedux = ConnectedProps<typeof connector>;

// コンポーネント定義
interface OwnProps {
  title: string;
}

type Props = PropsFromRedux & OwnProps;

function TodoListComponent({
  title,
  todos,
  filter,
  loading,
  addTodo,
  toggleTodo,
  removeTodo,
}: Props) {
  return (
    <div>
      <h1>{title}</h1>
      {loading && <p>読み込み中...</p>}
      <ul>
        {todos.map((todo) => (
          <li key={todo.id} onClick={() => toggleTodo(todo.id)}>
            {todo.text}
            <button onClick={() => removeTodo(todo.id)}>削除</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

// connect で接続
const TodoList = connector(TodoListComponent);

// 使用時
<TodoList title="マイタスク" />

7.6 reselect / createSelector によるパフォーマンス最適化

import { createSelector } from '@reduxjs/toolkit';

// 入力セレクター(Input Selectors)
const selectTodoItems = (state: RootState) => state.todos.items;
const selectFilter = (state: RootState) => state.todos.filter;
const selectSearchQuery = (state: RootState) => state.todos.searchQuery;

// メモ化されたセレクター
// 入力セレクターの結果が変わらない限り、前回の計算結果を返す
export const selectFilteredTodos = createSelector(
  [selectTodoItems, selectFilter],
  (items, filter) => {
    console.log('selectFilteredTodos: 再計算'); // デバッグ用
    switch (filter) {
      case 'active':
        return items.filter((t) => !t.completed);
      case 'completed':
        return items.filter((t) => t.completed);
      default:
        return items;
    }
  }
);

// セレクターの合成
export const selectSearchedTodos = createSelector(
  [selectFilteredTodos, selectSearchQuery],
  (filteredTodos, query) => {
    if (!query.trim()) return filteredTodos;
    const lowerQuery = query.toLowerCase();
    return filteredTodos.filter((t) =>
      t.text.toLowerCase().includes(lowerQuery)
    );
  }
);

// 統計情報のセレクター
export const selectTodoStatistics = createSelector(
  [selectTodoItems],
  (items) => {
    const total = items.length;
    const completed = items.filter((t) => t.completed).length;
    const active = total - completed;
    const byPriority = items.reduce(
      (acc, todo) => {
        acc[todo.priority] = (acc[todo.priority] || 0) + 1;
        return acc;
      },
      {} as Record<string, number>
    );

    return { total, completed, active, byPriority };
  }
);

// パラメータ付きセレクター(ファクトリパターン)
export const makeSelectTodosByCategory = () =>
  createSelector(
    [selectTodoItems, (_state: RootState, category: string) => category],
    (items, category) =>
      items.filter((item) => item.category === category)
  );

// コンポーネントでの使用
function CategoryTodos({ category }: { category: string }) {
  // useMemo でセレクターインスタンスを保持
  const selectTodosByCategory = useMemo(makeSelectTodosByCategory, []);
  const todos = useAppSelector((state) =>
    selectTodosByCategory(state, category)
  );

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

8. 状態設計パターン

8.1 正規化された状態(Normalized State)

データベースのテーブル設計と同様に、Redux の状態も正規化して管理することが推奨される。

// 非正規化(NG: データの重複、更新の困難さ)
interface DenormalizedState {
  posts: Array<{
    id: string;
    title: string;
    body: string;
    author: {
      id: string;
      name: string;
      avatar: string;
    };
    comments: Array<{
      id: string;
      text: string;
      author: {
        id: string;
        name: string;
        avatar: string;
      };
    }>;
  }>;
}

// 正規化(推奨: データの重複なし、更新が容易)
interface NormalizedState {
  users: {
    ids: string[];
    entities: Record<string, User>;
  };
  posts: {
    ids: string[];
    entities: Record<string, {
      id: string;
      title: string;
      body: string;
      authorId: string;      // ID で参照
      commentIds: string[];   // ID の配列で参照
    }>;
  };
  comments: {
    ids: string[];
    entities: Record<string, {
      id: string;
      text: string;
      authorId: string;       // ID で参照
      postId: string;
    }>;
  };
}

createEntityAdapter を使った正規化の実装は以下のようになる。

import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';

// Users
const usersAdapter = createEntityAdapter<User>();
const usersSlice = createSlice({
  name: 'users',
  initialState: usersAdapter.getInitialState(),
  reducers: {
    usersReceived: usersAdapter.upsertMany,
    userUpdated: usersAdapter.updateOne,
  },
});

// Posts
interface Post {
  id: string;
  title: string;
  body: string;
  authorId: string;
  commentIds: string[];
  createdAt: string;
}

const postsAdapter = createEntityAdapter<Post>({
  sortComparer: (a, b) => b.createdAt.localeCompare(a.createdAt),
});

const postsSlice = createSlice({
  name: 'posts',
  initialState: postsAdapter.getInitialState({
    loading: false,
    error: null as string | null,
  }),
  reducers: {
    postsReceived: postsAdapter.setAll,
    postAdded: postsAdapter.addOne,
    postUpdated: postsAdapter.updateOne,
    postRemoved: postsAdapter.removeOne,
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchPosts.pending, (state) => {
        state.loading = true;
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        postsAdapter.setAll(state, action.payload.posts);
        state.loading = false;
      });
  },
});

// セレクター
const postsSelectors = postsAdapter.getSelectors(
  (state: RootState) => state.posts
);
const usersSelectors = usersAdapter.getSelectors(
  (state: RootState) => state.users
);

// リレーションを解決するセレクター
const selectPostWithAuthor = createSelector(
  [
    (state: RootState, postId: string) =>
      postsSelectors.selectById(state, postId),
    (state: RootState) => state.users.entities,
  ],
  (post, userEntities) => {
    if (!post) return null;
    return {
      ...post,
      author: userEntities[post.authorId] ?? null,
    };
  }
);

8.2 API レスポンスの正規化

import { normalize, schema } from 'normalizr';

// normalizr でスキーマを定義
const userSchema = new schema.Entity('users');
const commentSchema = new schema.Entity('comments', {
  author: userSchema,
});
const postSchema = new schema.Entity('posts', {
  author: userSchema,
  comments: [commentSchema],
});

// API レスポンスの正規化
const apiResponse = {
  id: '1',
  title: 'Redux の使い方',
  author: { id: 'u1', name: '田中太郎' },
  comments: [
    {
      id: 'c1',
      text: '参考になりました',
      author: { id: 'u2', name: '鈴木花子' },
    },
    {
      id: 'c2',
      text: '分かりやすい!',
      author: { id: 'u1', name: '田中太郎' },
    },
  ],
};

const normalized = normalize(apiResponse, postSchema);
// 結果:
// {
//   result: '1',
//   entities: {
//     users: {
//       u1: { id: 'u1', name: '田中太郎' },
//       u2: { id: 'u2', name: '鈴木花子' },
//     },
//     comments: {
//       c1: { id: 'c1', text: '参考になりました', author: 'u2' },
//       c2: { id: 'c2', text: '分かりやすい!', author: 'u1' },
//     },
//     posts: {
//       1: { id: '1', title: 'Redux の使い方', author: 'u1', comments: ['c1', 'c2'] },
//     },
//   },
// }

// Thunk でレスポンスを正規化して dispatch
const fetchPosts = createAsyncThunk('posts/fetchAll', async () => {
  const response = await fetch('/api/posts');
  const data = await response.json();
  return normalize(data, [postSchema]);
});

8.3 スライスの構成パターン

// Feature-based スライス構成(推奨)
// src/features/todos/todosSlice.ts

interface TodosState {
  items: EntityState<Todo, string>;
  ui: {
    filter: FilterType;
    searchQuery: string;
    sortBy: SortType;
    selectedIds: string[];
    isSelectionMode: boolean;
  };
  async: {
    fetchStatus: 'idle' | 'loading' | 'succeeded' | 'failed';
    fetchError: string | null;
    saveStatus: 'idle' | 'saving' | 'succeeded' | 'failed';
    saveError: string | null;
  };
  pagination: {
    currentPage: number;
    pageSize: number;
    totalItems: number;
    totalPages: number;
  };
}

const todosAdapter = createEntityAdapter<Todo>();

const initialState: TodosState = {
  items: todosAdapter.getInitialState(),
  ui: {
    filter: 'all',
    searchQuery: '',
    sortBy: 'date',
    selectedIds: [],
    isSelectionMode: false,
  },
  async: {
    fetchStatus: 'idle',
    fetchError: null,
    saveStatus: 'idle',
    saveError: null,
  },
  pagination: {
    currentPage: 1,
    pageSize: 20,
    totalItems: 0,
    totalPages: 0,
  },
};

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    // エンティティ操作
    todoAdded: (state, action: PayloadAction<Todo>) => {
      todosAdapter.addOne(state.items, action.payload);
    },
    todoUpdated: (state, action: PayloadAction<Update<Todo, string>>) => {
      todosAdapter.updateOne(state.items, action.payload);
    },
    todoRemoved: (state, action: PayloadAction<string>) => {
      todosAdapter.removeOne(state.items, action.payload);
    },

    // UI 操作
    filterChanged: (state, action: PayloadAction<FilterType>) => {
      state.ui.filter = action.payload;
      state.pagination.currentPage = 1; // フィルター変更時はページをリセット
    },
    searchQueryChanged: (state, action: PayloadAction<string>) => {
      state.ui.searchQuery = action.payload;
    },
    todoSelected: (state, action: PayloadAction<string>) => {
      const id = action.payload;
      const index = state.ui.selectedIds.indexOf(id);
      if (index === -1) {
        state.ui.selectedIds.push(id);
      } else {
        state.ui.selectedIds.splice(index, 1);
      }
    },
    selectionCleared: (state) => {
      state.ui.selectedIds = [];
      state.ui.isSelectionMode = false;
    },

    // ページネーション
    pageChanged: (state, action: PayloadAction<number>) => {
      state.pagination.currentPage = action.payload;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchTodos.pending, (state) => {
        state.async.fetchStatus = 'loading';
        state.async.fetchError = null;
      })
      .addCase(fetchTodos.fulfilled, (state, action) => {
        state.async.fetchStatus = 'succeeded';
        todosAdapter.setAll(state.items, action.payload.items);
        state.pagination.totalItems = action.payload.total;
        state.pagination.totalPages = Math.ceil(
          action.payload.total / state.pagination.pageSize
        );
      })
      .addCase(fetchTodos.rejected, (state, action) => {
        state.async.fetchStatus = 'failed';
        state.async.fetchError = action.payload as string;
      });
  },
});

8.4 Loading / Error 状態の汎用パターン

// 汎用的な非同期状態の型
interface AsyncState<T> {
  data: T | null;
  status: 'idle' | 'loading' | 'succeeded' | 'failed';
  error: string | null;
  lastFetched: string | null;
}

// 汎用的な非同期状態の初期値を生成するヘルパー
function createAsyncState<T>(initialData: T | null = null): AsyncState<T> {
  return {
    data: initialData,
    status: 'idle',
    error: null,
    lastFetched: null,
  };
}

// 汎用的な extraReducers ビルダー
function addAsyncCases<T>(
  builder: ActionReducerMapBuilder<any>,
  thunk: AsyncThunk<T, any, any>,
  stateKey: string
) {
  builder
    .addCase(thunk.pending, (state) => {
      state[stateKey].status = 'loading';
      state[stateKey].error = null;
    })
    .addCase(thunk.fulfilled, (state, action) => {
      state[stateKey].status = 'succeeded';
      state[stateKey].data = action.payload;
      state[stateKey].lastFetched = new Date().toISOString();
    })
    .addCase(thunk.rejected, (state, action) => {
      state[stateKey].status = 'failed';
      state[stateKey].error = action.payload as string;
    });
}

// 使用例
interface DashboardState {
  users: AsyncState<User[]>;
  stats: AsyncState<DashboardStats>;
  recentActivity: AsyncState<Activity[]>;
}

const initialState: DashboardState = {
  users: createAsyncState<User[]>([]),
  stats: createAsyncState<DashboardStats>(),
  recentActivity: createAsyncState<Activity[]>([]),
};

const dashboardSlice = createSlice({
  name: 'dashboard',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    addAsyncCases(builder, fetchUsers, 'users');
    addAsyncCases(builder, fetchStats, 'stats');
    addAsyncCases(builder, fetchRecentActivity, 'recentActivity');
  },
});

// コンポーネントでの汎用ローディングコンポーネント
function AsyncContent<T>({
  asyncState,
  renderData,
  renderLoading,
  renderError,
}: {
  asyncState: AsyncState<T>;
  renderData: (data: T) => React.ReactNode;
  renderLoading?: () => React.ReactNode;
  renderError?: (error: string) => React.ReactNode;
}) {
  const { data, status, error } = asyncState;

  if (status === 'loading') {
    return <>{renderLoading?.() ?? <LoadingSkeleton />}</>;
  }

  if (status === 'failed') {
    return <>{renderError?.(error ?? '不明なエラー') ?? <ErrorMessage message={error} />}</>;
  }

  if (data === null) {
    return null;
  }

  return <>{renderData(data)}</>;
}

9. テスト戦略

Redux のアーキテクチャは、純粋関数と明示的なデータフローに基づいているため、テストが非常に書きやすい。

9.1 Reducer のテスト

import todosReducer, {
  addTodo,
  toggleTodo,
  removeTodo,
  setFilter,
  clearCompleted,
  TodosState,
} from './todosSlice';

describe('todosReducer', () => {
  const initialState: TodosState = {
    items: [],
    filter: 'all',
    searchQuery: '',
    loading: false,
    error: null,
  };

  it('初期状態を正しく返す', () => {
    const state = todosReducer(undefined, { type: 'unknown' });
    expect(state).toEqual(initialState);
  });

  it('addTodo でタスクを追加できる', () => {
    const state = todosReducer(
      initialState,
      addTodo({ text: 'テスト用タスク', priority: 'medium' })
    );

    expect(state.items).toHaveLength(1);
    expect(state.items[0]).toMatchObject({
      text: 'テスト用タスク',
      completed: false,
      priority: 'medium',
    });
    expect(state.items[0].id).toBeDefined();
    expect(state.items[0].createdAt).toBeDefined();
  });

  it('toggleTodo でタスクの完了状態を切り替えられる', () => {
    const stateWithTodo = {
      ...initialState,
      items: [
        {
          id: '1',
          text: 'テスト',
          completed: false,
          priority: 'medium' as const,
          createdAt: '2024-01-01',
        },
      ],
    };

    const state = todosReducer(stateWithTodo, toggleTodo('1'));
    expect(state.items[0].completed).toBe(true);

    // もう一度トグル
    const state2 = todosReducer(state, toggleTodo('1'));
    expect(state2.items[0].completed).toBe(false);
  });

  it('removeTodo でタスクを削除できる', () => {
    const stateWithTodos = {
      ...initialState,
      items: [
        { id: '1', text: 'タスク1', completed: false, priority: 'low' as const, createdAt: '' },
        { id: '2', text: 'タスク2', completed: true, priority: 'high' as const, createdAt: '' },
      ],
    };

    const state = todosReducer(stateWithTodos, removeTodo('1'));
    expect(state.items).toHaveLength(1);
    expect(state.items[0].id).toBe('2');
  });

  it('clearCompleted で完了済みタスクを一括削除できる', () => {
    const stateWithTodos = {
      ...initialState,
      items: [
        { id: '1', text: 'アクティブ', completed: false, priority: 'low' as const, createdAt: '' },
        { id: '2', text: '完了済み1', completed: true, priority: 'medium' as const, createdAt: '' },
        { id: '3', text: '完了済み2', completed: true, priority: 'high' as const, createdAt: '' },
      ],
    };

    const state = todosReducer(stateWithTodos, clearCompleted());
    expect(state.items).toHaveLength(1);
    expect(state.items[0].text).toBe('アクティブ');
  });

  it('setFilter でフィルターを変更できる', () => {
    const state = todosReducer(initialState, setFilter('completed'));
    expect(state.filter).toBe('completed');
  });

  it('元の状態を変更しない(イミュータビリティ)', () => {
    const originalState = {
      ...initialState,
      items: [
        { id: '1', text: 'タスク', completed: false, priority: 'low' as const, createdAt: '' },
      ],
    };

    const frozenState = Object.freeze(originalState);
    // Immer を使っているため、freeze された状態でもエラーにならない
    const newState = todosReducer(frozenState, toggleTodo('1'));

    expect(newState).not.toBe(originalState);
    expect(newState.items[0].completed).toBe(true);
  });
});

9.2 Selector のテスト

import {
  selectFilteredTodos,
  selectTodoStatistics,
  selectSearchedTodos,
} from './todosSelectors';

describe('Todo Selectors', () => {
  const createState = (overrides = {}): RootState => ({
    todos: {
      items: [
        { id: '1', text: 'Redux を学ぶ', completed: true, priority: 'high', createdAt: '2024-01-01' },
        { id: '2', text: 'テストを書く', completed: false, priority: 'medium', createdAt: '2024-01-02' },
        { id: '3', text: 'デプロイ', completed: false, priority: 'low', createdAt: '2024-01-03' },
      ],
      filter: 'all',
      searchQuery: '',
      loading: false,
      error: null,
      ...overrides,
    },
  } as RootState);

  describe('selectFilteredTodos', () => {
    it('filter="all" の場合、全タスクを返す', () => {
      const state = createState({ filter: 'all' });
      expect(selectFilteredTodos(state)).toHaveLength(3);
    });

    it('filter="active" の場合、未完了タスクのみ返す', () => {
      const state = createState({ filter: 'active' });
      const result = selectFilteredTodos(state);
      expect(result).toHaveLength(2);
      expect(result.every((t) => !t.completed)).toBe(true);
    });

    it('filter="completed" の場合、完了済みタスクのみ返す', () => {
      const state = createState({ filter: 'completed' });
      const result = selectFilteredTodos(state);
      expect(result).toHaveLength(1);
      expect(result[0].text).toBe('Redux を学ぶ');
    });

    it('メモ化が機能する(同じ入力で同じ参照を返す)', () => {
      const state = createState();
      const result1 = selectFilteredTodos(state);
      const result2 = selectFilteredTodos(state);
      expect(result1).toBe(result2); // 同一参照
    });
  });

  describe('selectTodoStatistics', () => {
    it('正しい統計情報を返す', () => {
      const state = createState();
      const stats = selectTodoStatistics(state);

      expect(stats).toEqual({
        total: 3,
        completed: 1,
        active: 2,
        byPriority: { high: 1, medium: 1, low: 1 },
      });
    });
  });

  describe('selectSearchedTodos', () => {
    it('検索クエリでフィルタリングできる', () => {
      const state = createState({ searchQuery: 'テスト' });
      const result = selectSearchedTodos(state);
      expect(result).toHaveLength(1);
      expect(result[0].text).toBe('テストを書く');
    });

    it('検索クエリが空の場合、フィルタ済みの全タスクを返す', () => {
      const state = createState({ searchQuery: '' });
      const result = selectSearchedTodos(state);
      expect(result).toHaveLength(3);
    });
  });
});

9.3 Thunk のテスト

import { configureStore } from '@reduxjs/toolkit';
import { fetchTodos, createTodo } from './todosThunks';
import todosReducer from './todosSlice';

// MSW(Mock Service Worker)によるAPIモック
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const mockTodos = [
  { id: '1', text: 'タスク1', completed: false },
  { id: '2', text: 'タスク2', completed: true },
];

const server = setupServer(
  http.get('/api/todos', () => {
    return HttpResponse.json(mockTodos);
  }),
  http.post('/api/todos', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({
      id: 'new-id',
      ...body,
    });
  }),
  http.delete('/api/todos/:id', ({ params }) => {
    return new HttpResponse(null, { status: 204 });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('Todos Thunks', () => {
  let store: ReturnType<typeof configureStore>;

  beforeEach(() => {
    store = configureStore({
      reducer: { todos: todosReducer },
    });
  });

  describe('fetchTodos', () => {
    it('成功時にタスクを状態に格納する', async () => {
      await store.dispatch(fetchTodos());

      const state = store.getState().todos;
      expect(state.items).toEqual(mockTodos);
      expect(state.loading).toBe(false);
      expect(state.error).toBeNull();
    });

    it('失敗時にエラーを状態に格納する', async () => {
      server.use(
        http.get('/api/todos', () => {
          return new HttpResponse(null, { status: 500 });
        })
      );

      await store.dispatch(fetchTodos());

      const state = store.getState().todos;
      expect(state.items).toEqual([]);
      expect(state.loading).toBe(false);
      expect(state.error).toBeTruthy();
    });

    it('ローディング状態を正しく管理する', async () => {
      const promise = store.dispatch(fetchTodos());

      // pending 状態
      expect(store.getState().todos.loading).toBe(true);

      await promise;

      // fulfilled 状態
      expect(store.getState().todos.loading).toBe(false);
    });
  });

  describe('createTodo', () => {
    it('新しいタスクを作成して状態に追加する', async () => {
      const result = await store.dispatch(
        createTodo({ text: '新しいタスク', completed: false })
      ).unwrap();

      expect(result).toMatchObject({
        id: 'new-id',
        text: '新しいタスク',
      });
    });
  });
});

9.4 コンポーネントの統合テスト

import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test-utils';
import TodoApp from './TodoApp';

// テストユーティリティの作成
// test-utils.tsx
import { configureStore } from '@reduxjs/toolkit';
import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import todosReducer from '../features/todos/todosSlice';
import type { RootState } from '../lib/store';

export function renderWithProviders(
  ui: React.ReactElement,
  {
    preloadedState = {} as Partial<RootState>,
    store = configureStore({
      reducer: { todos: todosReducer },
      preloadedState,
    }),
    ...renderOptions
  } = {}
) {
  function Wrapper({ children }: { children: React.ReactNode }) {
    return <Provider store={store}>{children}</Provider>;
  }

  return {
    store,
    ...render(ui, { wrapper: Wrapper, ...renderOptions }),
  };
}

// テストの実装
describe('TodoApp', () => {
  it('タスク一覧を表示する', () => {
    renderWithProviders(<TodoApp />, {
      preloadedState: {
        todos: {
          items: [
            { id: '1', text: 'テストタスク', completed: false, priority: 'medium', createdAt: '' },
          ],
          filter: 'all',
          searchQuery: '',
          loading: false,
          error: null,
        },
      },
    });

    expect(screen.getByText('テストタスク')).toBeInTheDocument();
  });

  it('新しいタスクを追加できる', async () => {
    const user = userEvent.setup();
    renderWithProviders(<TodoApp />);

    const input = screen.getByPlaceholderText('新しいタスクを入力');
    const addButton = screen.getByRole('button', { name: '追加' });

    await user.type(input, '新しいタスク');
    await user.click(addButton);

    expect(screen.getByText('新しいタスク')).toBeInTheDocument();
    expect(input).toHaveValue(''); // 入力欄がクリアされている
  });

  it('タスクの完了状態を切り替えられる', async () => {
    const user = userEvent.setup();
    renderWithProviders(<TodoApp />, {
      preloadedState: {
        todos: {
          items: [
            { id: '1', text: 'タスク', completed: false, priority: 'medium', createdAt: '' },
          ],
          filter: 'all',
          searchQuery: '',
          loading: false,
          error: null,
        },
      },
    });

    const checkbox = screen.getByRole('checkbox');
    await user.click(checkbox);

    expect(checkbox).toBeChecked();
  });

  it('フィルターが正しく動作する', async () => {
    const user = userEvent.setup();
    renderWithProviders(<TodoApp />, {
      preloadedState: {
        todos: {
          items: [
            { id: '1', text: 'アクティブ', completed: false, priority: 'low', createdAt: '' },
            { id: '2', text: '完了済み', completed: true, priority: 'high', createdAt: '' },
          ],
          filter: 'all',
          searchQuery: '',
          loading: false,
          error: null,
        },
      },
    });

    // 「完了済み」フィルターをクリック
    await user.click(screen.getByRole('button', { name: '完了済み' }));

    expect(screen.queryByText('アクティブ')).not.toBeInTheDocument();
    expect(screen.getByText('完了済み')).toBeInTheDocument();
  });

  it('ローディング中はスケルトンを表示する', () => {
    renderWithProviders(<TodoApp />, {
      preloadedState: {
        todos: {
          items: [],
          filter: 'all',
          searchQuery: '',
          loading: true,
          error: null,
        },
      },
    });

    expect(screen.getByTestId('loading-skeleton')).toBeInTheDocument();
  });

  it('エラー時にエラーメッセージを表示する', () => {
    renderWithProviders(<TodoApp />, {
      preloadedState: {
        todos: {
          items: [],
          filter: 'all',
          searchQuery: '',
          loading: false,
          error: 'ネットワークエラーが発生しました',
        },
      },
    });

    expect(
      screen.getByText('ネットワークエラーが発生しました')
    ).toBeInTheDocument();
  });
});

9.5 RTK Query のテスト

import { renderWithProviders } from '../../test-utils';
import { apiSlice } from '../services/api';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { screen, waitFor } from '@testing-library/react';
import TodoList from './TodoList';

const server = setupServer(
  http.get('/api/todos', () => {
    return HttpResponse.json([
      { id: '1', text: 'テスト', completed: false },
    ]);
  })
);

beforeAll(() => server.listen());
afterEach(() => {
  server.resetHandlers();
  // RTK Query のキャッシュをリセット
});
afterAll(() => server.close());

describe('TodoList with RTK Query', () => {
  it('API からデータを取得して表示する', async () => {
    renderWithProviders(<TodoList />);

    // ローディング中
    expect(screen.getByText('読み込み中...')).toBeInTheDocument();

    // データ取得後
    await waitFor(() => {
      expect(screen.getByText('テスト')).toBeInTheDocument();
    });
  });

  it('API エラー時にエラーメッセージを表示する', async () => {
    server.use(
      http.get('/api/todos', () => {
        return new HttpResponse(null, { status: 500 });
      })
    );

    renderWithProviders(<TodoList />);

    await waitFor(() => {
      expect(screen.getByText(/エラー/)).toBeInTheDocument();
    });
  });
});

10. パフォーマンス最適化

Redux アプリケーションのパフォーマンスは、適切な設計と最適化によって大幅に改善できる。

10.1 メモ化されたセレクター

セレクターのメモ化は、最も効果的なパフォーマンス最適化の一つである。

import { createSelector } from '@reduxjs/toolkit';

// 高コストな計算をメモ化する
const selectExpensiveData = createSelector(
  [
    (state: RootState) => state.orders.items,
    (state: RootState) => state.products.entities,
    (state: RootState) => state.users.entities,
  ],
  (orders, products, users) => {
    // この計算は入力が変わらない限り再実行されない
    return orders.map((order) => ({
      ...order,
      product: products[order.productId],
      user: users[order.userId],
      totalWithTax: order.total * 1.1,
      formattedDate: new Intl.DateTimeFormat('ja-JP').format(
        new Date(order.createdAt)
      ),
    }));
  }
);

// セレクターの合成でパフォーマンスを向上
const selectOrders = (state: RootState) => state.orders.items;

// 段階的にフィルタリング(各段階でメモ化される)
const selectOrdersByStatus = createSelector(
  [selectOrders, (_, status: string) => status],
  (orders, status) => orders.filter((o) => o.status === status)
);

const selectOrdersByDateRange = createSelector(
  [selectOrdersByStatus, (_, __, startDate: string) => startDate, (_, __, ___, endDate: string) => endDate],
  (orders, startDate, endDate) =>
    orders.filter(
      (o) => o.createdAt >= startDate && o.createdAt <= endDate
    )
);

// createSelector のメモ化サイズをカスタマイズ
import { lruMemoize, createSelectorCreator } from '@reduxjs/toolkit';

// 複数の引数パターンをキャッシュするセレクター
const createMultiCacheSelector = createSelectorCreator({
  memoize: lruMemoize,
  memoizeOptions: { maxSize: 10 }, // 最大10パターンをキャッシュ
});

const selectTodosByCategory = createMultiCacheSelector(
  [(state: RootState) => state.todos.items, (_, category: string) => category],
  (items, category) => items.filter((t) => t.category === category)
);

10.2 React.memo による再レンダリングの最適化

import { memo, useMemo, useCallback } from 'react';

// React.memo で不要な再レンダリングを防止
const TodoItem = memo(function TodoItem({
  id,
  text,
  completed,
  onToggle,
  onRemove,
}: {
  id: string;
  text: string;
  completed: boolean;
  onToggle: (id: string) => void;
  onRemove: (id: string) => void;
}) {
  console.log(`TodoItem ${id} rendered`); // デバッグ用

  return (
    <li className={completed ? 'completed' : ''}>
      <input
        type="checkbox"
        checked={completed}
        onChange={() => onToggle(id)}
      />
      <span>{text}</span>
      <button onClick={() => onRemove(id)}>削除</button>
    </li>
  );
});

// 親コンポーネント
function TodoList() {
  const todos = useAppSelector(selectFilteredTodos);
  const dispatch = useAppDispatch();

  // useCallback でコールバック関数をメモ化
  const handleToggle = useCallback(
    (id: string) => {
      dispatch(toggleTodo(id));
    },
    [dispatch]
  );

  const handleRemove = useCallback(
    (id: string) => {
      dispatch(removeTodo(id));
    },
    [dispatch]
  );

  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          id={todo.id}
          text={todo.text}
          completed={todo.completed}
          onToggle={handleToggle}
          onRemove={handleRemove}
        />
      ))}
    </ul>
  );
}

// 個別の useSelector で最小限の再レンダリング
const OptimizedTodoItem = memo(function OptimizedTodoItem({
  todoId,
}: {
  todoId: string;
}) {
  // このコンポーネントは、この特定の todo が変更された時のみ再レンダリングされる
  const todo = useAppSelector((state) =>
    state.todos.items.find((t) => t.id === todoId)
  );
  const dispatch = useAppDispatch();

  if (!todo) return null;

  return (
    <li>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => dispatch(toggleTodo(todoId))}
      />
      <span>{todo.text}</span>
    </li>
  );
});

// ID リストのみを親で管理
function OptimizedTodoList() {
  // ID 配列のみを監視(個別のアイテム変更では再レンダリングされない)
  const todoIds = useAppSelector((state) =>
    state.todos.items.map((t) => t.id)
  );

  return (
    <ul>
      {todoIds.map((id) => (
        <OptimizedTodoItem key={id} todoId={id} />
      ))}
    </ul>
  );
}

10.3 不要な再レンダリングの回避

// パターン1: オブジェクトリテラルの回避
function BadComponent() {
  const data = useAppSelector((state) => ({
    // 毎回新しいオブジェクトが作成される → 毎回再レンダリング
    name: state.user.name,
    email: state.user.email,
  }));
  return <UserInfo data={data} />;
}

function GoodComponent() {
  // 個別の useSelector に分割する
  const name = useAppSelector((state) => state.user.name);
  const email = useAppSelector((state) => state.user.email);
  return <UserInfo name={name} email={email} />;
}

// パターン2: createSelector を使ったオブジェクト生成のメモ化
const selectUserInfo = createSelector(
  [(state: RootState) => state.user.name, (state: RootState) => state.user.email],
  (name, email) => ({ name, email }) // 入力が同じなら同じオブジェクト参照
);

function BetterComponent() {
  const userInfo = useAppSelector(selectUserInfo);
  return <UserInfo data={userInfo} />;
}

// パターン3: useSelector 内での変換の回避
function BadFilterExample() {
  // filter() は毎回新しい配列を生成 → 毎回再レンダリング
  const activeTodos = useAppSelector((state) =>
    state.todos.items.filter((t) => !t.completed)
  );
  return <TodoList todos={activeTodos} />;
}

// createSelector で解決
const selectActiveTodos = createSelector(
  [(state: RootState) => state.todos.items],
  (items) => items.filter((t) => !t.completed)
);

function GoodFilterExample() {
  const activeTodos = useAppSelector(selectActiveTodos);
  return <TodoList todos={activeTodos} />;
}

10.4 大規模な状態ツリーの最適化

// 状態の分割と遅延読み込み
// 大きなスライスを複数の小さなスライスに分割する

// 分割前(一つの巨大なスライス)
interface MonolithicState {
  dashboard: DashboardData;
  analytics: AnalyticsData;
  settings: SettingsData;
  reports: ReportsData;
  // ... 大量のプロパティ
}

// 分割後(機能ごとに独立したスライス)
const store = configureStore({
  reducer: {
    dashboard: dashboardReducer,     // ダッシュボード機能
    analytics: analyticsReducer,     // 分析機能
    settings: settingsReducer,       // 設定画面
    reports: reportsReducer,         // レポート機能
  },
});

// 正規化による検索パフォーマンスの向上
// NG: 配列での検索(O(n))
const selectTodoById = (state: RootState, id: string) =>
  state.todos.items.find((t) => t.id === id); // 毎回リニアサーチ

// OK: エンティティ辞書での検索(O(1))
const selectTodoById = (state: RootState, id: string) =>
  state.todos.entities[id]; // 即座にアクセス

// バッチ更新によるレンダリング回数の削減
import { unstable_batchedUpdates } from 'react-dom';

// React 18 では自動バッチングが有効なため、通常は不要
// ただし setTimeout などの非同期コールバック内では明示的なバッチが有効な場合がある
function batchExample() {
  return (dispatch: AppDispatch) => {
    // React 18 では自動でバッチされる
    dispatch(updateFilter('active'));
    dispatch(setPage(1));
    dispatch(clearSelection());
    // → 1回の再レンダリングのみ発生
  };
}

10.5 パフォーマンス計測

// Redux DevTools のパフォーマンスモニタリング
const store = configureStore({
  reducer: rootReducer,
  devTools: {
    // アクションごとの処理時間を記録
    trace: true,
    traceLimit: 25,
    // 状態の差分を効率的に計算
    stateSanitizer: (state: RootState) => ({
      ...state,
      // 大きなデータを省略して DevTools のパフォーマンスを維持
      largeData: '<<省略>>',
    }),
    actionSanitizer: (action) => {
      if (action.type === 'data/received' && action.payload?.length > 100) {
        return {
          ...action,
          payload: `<<${action.payload.length}件のアイテム>>`,
        };
      }
      return action;
    },
  },
});

// カスタムパフォーマンスミドルウェア
const performanceMiddleware: Middleware = () => (next) => (action) => {
  const start = performance.now();
  const result = next(action);
  const duration = performance.now() - start;

  if (duration > 16) {
    // 1フレーム(16ms)以上かかった場合に警告
    console.warn(
      `Slow action: ${action.type} took ${duration.toFixed(2)}ms`
    );
  }

  return result;
};

// React Profiler との組み合わせ
function ProfilingWrapper({ children }: { children: React.ReactNode }) {
  const onRender = (
    id: string,
    phase: 'mount' | 'update',
    actualDuration: number
  ) => {
    if (actualDuration > 16) {
      console.warn(
        `Slow render: ${id} (${phase}) took ${actualDuration.toFixed(2)}ms`
      );
    }
  };

  return (
    <Profiler id="App" onRender={onRender}>
      {children}
    </Profiler>
  );
}

11. 高度なパターン

11.1 動的 Reducer の注入(Code Splitting)

大規模アプリケーションでは、ルートごとに必要な Reducer のみをロードすることで初期バンドルサイズを削減できる。

// lib/store.ts - 動的 Reducer 注入をサポートするストア設定
import { configureStore, combineReducers, Reducer } from '@reduxjs/toolkit';

// 常に必要なコア Reducer
const staticReducers = {
  auth: authReducer,
  ui: uiReducer,
};

function createReducerManager(initialReducers: Record<string, Reducer>) {
  const reducers = { ...initialReducers };
  let combinedReducer = combineReducers(reducers);
  let keysToRemove: string[] = [];

  return {
    getReducerMap: () => reducers,

    // 現在の結合された Reducer を取得
    reduce: (state: any, action: any) => {
      // 削除予定のキーがある場合、状態からも削除
      if (keysToRemove.length > 0) {
        state = { ...state };
        for (const key of keysToRemove) {
          delete state[key];
        }
        keysToRemove = [];
      }
      return combinedReducer(state, action);
    },

    // 新しい Reducer を動的に追加
    add: (key: string, reducer: Reducer) => {
      if (!key || reducers[key]) return;
      reducers[key] = reducer;
      combinedReducer = combineReducers(reducers);
    },

    // Reducer を動的に削除
    remove: (key: string) => {
      if (!key || !reducers[key]) return;
      delete reducers[key];
      keysToRemove.push(key);
      combinedReducer = combineReducers(reducers);
    },
  };
}

// ストアの作成
const reducerManager = createReducerManager(staticReducers);

const store = configureStore({
  reducer: reducerManager.reduce as Reducer,
  middleware: (getDefault) => getDefault(),
});

// Store に reducerManager を公開
(store as any).reducerManager = reducerManager;

// ルートコンポーネントでの動的注入
// ルートレベルでの使用例(React Router + lazy loading)
import { lazy, Suspense } from 'react';

const DashboardPage = lazy(async () => {
  // ページコンポーネントと Reducer を並行してロード
  const [module, { default: dashboardReducer }] = await Promise.all([
    import('./pages/Dashboard'),
    import('./features/dashboard/dashboardSlice'),
  ]);

  // Reducer を動的に注入
  (store as any).reducerManager.add('dashboard', dashboardReducer);

  return module;
});

// Redux Toolkit の combineSlices(RTK 2.0+)を使った方法
import { combineSlices } from '@reduxjs/toolkit';

// 静的スライスの結合
const rootReducer = combineSlices(authSlice, uiSlice);

// 動的スライスの注入用の型宣言
declare module '@reduxjs/toolkit' {
  interface LazyLoadedSlices {
    dashboard: DashboardState;
    analytics: AnalyticsState;
  }
}

// 動的注入
const injectedReducer = rootReducer.inject(dashboardSlice);

// Store での使用
const store = configureStore({
  reducer: rootReducer,
});

11.2 Undo/Redo パターン

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

// 汎用的な Undo/Redo ラッパー
interface UndoableState<T> {
  past: T[];
  present: T;
  future: T[];
}

function createUndoableSlice<T>(
  name: string,
  initialPresent: T,
  reducers: Record<string, (state: T, action: any) => T | void>
) {
  const initialState: UndoableState<T> = {
    past: [],
    present: initialPresent,
    future: [],
  };

  return createSlice({
    name,
    initialState,
    reducers: {
      // Undo
      undo: (state) => {
        if (state.past.length === 0) return;
        const previous = state.past[state.past.length - 1];
        state.past.pop();
        state.future.unshift(state.present as T);
        state.present = previous;
      },

      // Redo
      redo: (state) => {
        if (state.future.length === 0) return;
        const next = state.future[0];
        state.future.shift();
        state.past.push(state.present as T);
        state.present = next;
      },

      // 履歴をクリア
      clearHistory: (state) => {
        state.past = [];
        state.future = [];
      },

      // 元の Reducer をラップ
      ...Object.fromEntries(
        Object.entries(reducers).map(([key, reducer]) => [
          key,
          (state: UndoableState<T>, action: any) => {
            // 現在の状態を past に追加
            state.past.push(JSON.parse(JSON.stringify(state.present)));
            // 履歴の最大数を制限
            if (state.past.length > 50) {
              state.past.shift();
            }
            // future をクリア(新しい変更があった場合)
            state.future = [];
            // Reducer を実行
            const result = reducer(state.present as T, action);
            if (result !== undefined) {
              state.present = result;
            }
          },
        ])
      ),
    },
  });
}

// 使用例: ドローイングアプリ
interface CanvasState {
  shapes: Shape[];
  selectedShapeId: string | null;
  backgroundColor: string;
}

const canvasSlice = createUndoableSlice(
  'canvas',
  {
    shapes: [],
    selectedShapeId: null,
    backgroundColor: '#ffffff',
  } as CanvasState,
  {
    addShape: (state, action: PayloadAction<Shape>) => {
      state.shapes.push(action.payload);
    },
    moveShape: (state, action: PayloadAction<{ id: string; x: number; y: number }>) => {
      const shape = state.shapes.find((s) => s.id === action.payload.id);
      if (shape) {
        shape.x = action.payload.x;
        shape.y = action.payload.y;
      }
    },
    deleteShape: (state, action: PayloadAction<string>) => {
      state.shapes = state.shapes.filter((s) => s.id !== action.payload);
    },
  }
);

// コンポーネントでの使用
function CanvasToolbar() {
  const dispatch = useAppDispatch();
  const canUndo = useAppSelector((state) => state.canvas.past.length > 0);
  const canRedo = useAppSelector((state) => state.canvas.future.length > 0);

  return (
    <div>
      <button onClick={() => dispatch(canvasSlice.actions.undo())} disabled={!canUndo}>
        元に戻す (Ctrl+Z)
      </button>
      <button onClick={() => dispatch(canvasSlice.actions.redo())} disabled={!canRedo}>
        やり直し (Ctrl+Y)
      </button>
    </div>
  );
}

11.3 redux-persist による永続化

import { configureStore } from '@reduxjs/toolkit';
import {
  persistStore,
  persistReducer,
  FLUSH,
  REHYDRATE,
  PAUSE,
  PERSIST,
  PURGE,
  REGISTER,
} from 'redux-persist';
import storage from 'redux-persist/lib/storage'; // localStorage
import sessionStorage from 'redux-persist/lib/storage/session'; // sessionStorage

// 永続化の設定
const authPersistConfig = {
  key: 'auth',
  storage,
  whitelist: ['token', 'user'], // 永続化するフィールド
  // blacklist: ['loading', 'error'], // 永続化しないフィールド
};

const uiPersistConfig = {
  key: 'ui',
  storage: sessionStorage, // セッションストレージに保存
  whitelist: ['theme', 'sidebarCollapsed', 'language'],
};

// Reducer ごとに永続化設定を適用
const rootReducer = combineReducers({
  auth: persistReducer(authPersistConfig, authReducer),
  ui: persistReducer(uiPersistConfig, uiReducer),
  todos: todosReducer,    // 永続化しない
  api: apiSlice.reducer,  // 永続化しない
});

const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        // redux-persist のアクションをチェック対象外にする
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }).concat(apiSlice.middleware),
});

const persistor = persistStore(store);

// App コンポーネント
import { PersistGate } from 'redux-persist/integration/react';

function App() {
  return (
    <Provider store={store}>
      <PersistGate loading={<LoadingScreen />} persistor={persistor}>
        <AppContent />
      </PersistGate>
    </Provider>
  );
}

// 暗号化された永続化(機密データ向け)
import { encryptTransform } from 'redux-persist-transform-encrypt';

const encryptor = encryptTransform({
  secretKey: process.env.NEXT_PUBLIC_PERSIST_SECRET!,
  onError: (error) => {
    console.error('永続化の暗号化エラー:', error);
  },
});

const securePersistConfig = {
  key: 'secure',
  storage,
  transforms: [encryptor],
};

11.4 SSR(サーバーサイドレンダリング)での考慮事項

// Next.js App Router での Redux SSR

// lib/store.ts
import { configureStore } from '@reduxjs/toolkit';

// Store はリクエストごとに新規作成する(シングルトンにしない)
export const makeStore = (preloadedState?: Partial<RootState>) => {
  return configureStore({
    reducer: {
      todos: todosReducer,
      auth: authReducer,
      [apiSlice.reducerPath]: apiSlice.reducer,
    },
    preloadedState,
    middleware: (getDefault) => getDefault().concat(apiSlice.middleware),
  });
};

// providers.tsx
'use client';

import { useRef } from 'react';
import { Provider } from 'react-redux';
import { makeStore, AppStore } from '../lib/store';
import type { RootState } from '../lib/store';

interface Props {
  children: React.ReactNode;
  preloadedState?: Partial<RootState>;
}

export default function StoreProvider({ children, preloadedState }: Props) {
  const storeRef = useRef<AppStore | null>(null);
  if (!storeRef.current) {
    storeRef.current = makeStore(preloadedState);
  }

  return <Provider store={storeRef.current}>{children}</Provider>;
}

// Server Component からの初期データ受け渡し
// app/todos/page.tsx
import StoreProvider from '../providers';
import TodoList from '../components/TodoList';

export default async function TodosPage() {
  // サーバー側でデータを取得
  const response = await fetch('https://api.example.com/todos', {
    next: { revalidate: 60 }, // ISR: 60秒
  });
  const todos = await response.json();

  // 初期状態としてクライアントに渡す
  const preloadedState = {
    todos: {
      items: todos,
      filter: 'all',
      loading: false,
      error: null,
    },
  };

  return (
    <StoreProvider preloadedState={preloadedState}>
      <TodoList />
    </StoreProvider>
  );
}

11.5 リスナーミドルウェア(Listener Middleware)

Redux Toolkit 2.0 で追加された createListenerMiddleware は、副作用を管理するための新しいパターンである。

import { createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit';

const listenerMiddleware = createListenerMiddleware();

// 特定のアクションに反応するリスナー
listenerMiddleware.startListening({
  actionCreator: addTodo,
  effect: async (action, listenerApi) => {
    // 非同期の副作用を実行
    console.log('新しい Todo が追加されました:', action.payload);

    // 状態にアクセス
    const state = listenerApi.getState() as RootState;
    const totalTodos = state.todos.items.length;

    // 条件付きで別のアクションを dispatch
    if (totalTodos >= 10) {
      listenerApi.dispatch(
        showNotification({ message: 'タスクが10個を超えました!', type: 'warning' })
      );
    }

    // ローカルストレージに保存
    localStorage.setItem('todos-count', String(totalTodos));
  },
});

// 複数のアクションを監視
listenerMiddleware.startListening({
  matcher: isAnyOf(addTodo, removeTodo, toggleTodo),
  effect: async (action, listenerApi) => {
    // デバウンス: 500ms 以内に同じリスナーが再トリガーされたらキャンセル
    listenerApi.cancelActiveListeners();
    await listenerApi.delay(500);

    // 状態をサーバーに同期
    const state = listenerApi.getState() as RootState;
    await fetch('/api/sync', {
      method: 'POST',
      body: JSON.stringify(state.todos.items),
    });
  },
});

// 状態の変化を監視するリスナー
listenerMiddleware.startListening({
  predicate: (action, currentState, previousState) => {
    // auth 状態が変化した場合にのみ実行
    return (currentState as RootState).auth.isAuthenticated !==
           (previousState as RootState).auth.isAuthenticated;
  },
  effect: async (action, listenerApi) => {
    const state = listenerApi.getState() as RootState;
    if (state.auth.isAuthenticated) {
      // ログイン時: ユーザーデータを取得
      listenerApi.dispatch(fetchUserProfile());
      listenerApi.dispatch(fetchNotifications());
    } else {
      // ログアウト時: キャッシュをクリア
      listenerApi.dispatch(apiSlice.util.resetApiState());
    }
  },
});

// Store に追加
const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefault) =>
    getDefault().prepend(listenerMiddleware.middleware),
});

12. デバッグと開発ツール

12.1 Redux DevTools

Redux DevTools は Redux アプリケーションのデバッグに不可欠なブラウザ拡張機能である。

// Redux DevTools は configureStore で自動的に有効化される
const store = configureStore({
  reducer: rootReducer,
  // 開発環境でのみ有効(デフォルト動作)
  devTools: process.env.NODE_ENV !== 'production',
});

// 高度な DevTools 設定
const store = configureStore({
  reducer: rootReducer,
  devTools: {
    // DevTools の表示名
    name: 'My Application',

    // 保持するアクションの最大数
    maxAge: 100,

    // アクションのスタックトレースを記録
    trace: true,
    traceLimit: 25,

    // 特定のアクションを DevTools に表示しない
    actionsDenylist: ['ui/mouseMoved', 'ui/scrolled'],

    // または特定のアクションのみ表示
    // actionsAllowlist: ['todos/', 'auth/'],

    // 大きな状態を DevTools に渡す前にサニタイズ
    stateSanitizer: (state: RootState) => ({
      ...state,
      api: '<<RTK Query State>>',
    }),

    // 大きなペイロードをサニタイズ
    actionSanitizer: (action) => {
      if (action.type === 'data/largePayload') {
        return {
          ...action,
          payload: `<<${(action.payload as any[]).length} items>>`,
        };
      }
      return action;
    },

    // アクション生成時のタイムスタンプフォーマット
    serialize: {
      options: {
        date: true,    // Date オブジェクトをシリアライズ
        regexp: true,  // RegExp をシリアライズ
        map: true,     // Map をシリアライズ
        set: true,     // Set をシリアライズ
      },
    },
  },
});

DevTools で利用可能な機能は以下の通りである。

Redux DevTools の主要機能:

1. アクション履歴
   ├── 全 dispatch されたアクションの一覧
   ├── アクションの payload の詳細表示
   └── アクション発行時のスタックトレース

2. 状態ビューア
   ├── Tree: ツリー形式で状態を表示
   ├── Chart: グラフ形式で状態を可視化
   └── Raw: JSON 形式で状態を表示

3. Diff ビュー
   └── アクション前後の状態の差分をハイライト表示

4. タイムトラベルデバッグ
   ├── スライダーで過去の状態に戻る
   ├── 特定のアクションをスキップ(無効化)
   └── 任意の時点から状態を再生

5. ディスパッチ
   └── DevTools から直接アクションを dispatch

6. インポート/エクスポート
   ├── 状態のスナップショットをファイルに保存
   └── 保存したスナップショットを読み込んで再現

12.2 タイムトラベルデバッグ

// タイムトラベルデバッグの活用例

// 1. バグの再現手順:
//    a. DevTools でアクション履歴を確認
//    b. バグが発生した直前のアクションを特定
//    c. そのアクション前の状態を確認
//    d. アクション後の状態変化をDiffで確認
//    e. 問題の原因を特定

// 2. 状態のスナップショット保存(バグレポート用)
// DevTools → Export → state.json

// 3. 保存した状態をテストで再現
import savedState from './debug/bug-report-state.json';

const store = configureStore({
  reducer: rootReducer,
  preloadedState: savedState,
});

// 4. プログラムからの DevTools 操作
// DevTools Extension API を使って状態をインポート
if (window.__REDUX_DEVTOOLS_EXTENSION__) {
  window.__REDUX_DEVTOOLS_EXTENSION__.send(
    { type: 'DEBUG/SET_STATE' },
    debugState
  );
}

12.3 カスタムログミドルウェア

// 開発環境用の詳細ログミドルウェア
const createLoggerMiddleware = (options: {
  collapsed?: boolean;
  diff?: boolean;
  predicate?: (state: any, action: any) => boolean;
  colors?: boolean;
} = {}) => {
  const {
    collapsed = true,
    diff = true,
    predicate = () => true,
    colors = true,
  } = options;

  const middleware: Middleware<{}, RootState> =
    (store) => (next) => (action) => {
      const prevState = store.getState();

      // predicate で出力するかどうかをフィルタ
      if (!predicate(prevState, action)) {
        return next(action);
      }

      const startTime = performance.now();
      const result = next(action);
      const endTime = performance.now();
      const duration = endTime - startTime;
      const nextState = store.getState();

      const groupMethod = collapsed ? 'groupCollapsed' : 'group';

      // ヘッダー
      const headerColor = colors ? 'color: gray; font-weight: lighter' : '';
      const titleColor = colors ? 'color: inherit; font-weight: bold' : '';
      const durationColor =
        duration > 16
          ? 'color: red; font-weight: bold'
          : 'color: gray; font-weight: lighter';

      console[groupMethod](
        `%c action %c${action.type} %c(${duration.toFixed(2)}ms)`,
        headerColor,
        titleColor,
        durationColor
      );

      // 前の状態
      console.log('%c prev state', 'color: #9E9E9E; font-weight: bold', prevState);

      // アクション
      console.log('%c action    ', 'color: #03A9F4; font-weight: bold', action);

      // 次の状態
      console.log('%c next state', 'color: #4CAF50; font-weight: bold', nextState);

      // 差分
      if (diff) {
        const changes = findDifferences(prevState, nextState);
        if (Object.keys(changes).length > 0) {
          console.log('%c diff      ', 'color: #E040FB; font-weight: bold', changes);
        }
      }

      console.groupEnd();
      return result;
    };

  return middleware;
};

// 簡易的な差分検出
function findDifferences(obj1: any, obj2: any, path = ''): Record<string, any> {
  const changes: Record<string, any> = {};

  if (obj1 === obj2) return changes;

  if (typeof obj1 !== typeof obj2 || obj1 === null || obj2 === null) {
    changes[path || 'root'] = { from: obj1, to: obj2 };
    return changes;
  }

  if (typeof obj1 === 'object') {
    const allKeys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
    for (const key of allKeys) {
      const newPath = path ? `${path}.${key}` : key;
      if (obj1[key] !== obj2[key]) {
        if (typeof obj1[key] === 'object' && typeof obj2[key] === 'object') {
          Object.assign(changes, findDifferences(obj1[key], obj2[key], newPath));
        } else {
          changes[newPath] = { from: obj1[key], to: obj2[key] };
        }
      }
    }
  }

  return changes;
}

// ストアへの追加
const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefault) =>
    getDefault().concat(
      process.env.NODE_ENV === 'development'
        ? createLoggerMiddleware({
            collapsed: true,
            diff: true,
            // 頻繁に発生するアクションを除外
            predicate: (_, action) =>
              !['ui/mouseMoved', 'ui/scrollPosition'].includes(action.type),
          })
        : []
    ),
});

12.4 RTK Query のデバッグ

// RTK Query のキャッシュ状態をデバッグ用に表示するコンポーネント
function RTKQueryDebugger() {
  const apiState = useAppSelector((state) => state.api);

  if (process.env.NODE_ENV !== 'development') return null;

  return (
    <details style={{ position: 'fixed', bottom: 0, right: 0, zIndex: 9999 }}>
      <summary>RTK Query Cache</summary>
      <pre style={{ maxHeight: '400px', overflow: 'auto', fontSize: '11px' }}>
        {JSON.stringify(
          {
            queries: Object.entries(apiState.queries).map(([key, value]) => ({
              key,
              status: value?.status,
              fulfilledTimeStamp: value?.fulfilledTimeStamp,
              data: value?.data ? '<<data>>' : null,
            })),
            mutations: Object.entries(apiState.mutations).map(
              ([key, value]) => ({
                key,
                status: value?.status,
              })
            ),
          },
          null,
          2
        )}
      </pre>
    </details>
  );
}

13. 他の状態管理ライブラリとの比較

13.1 React Context API

// Context API: React 組み込みの状態共有メカニズム
import { createContext, useContext, useReducer } from 'react';

interface AppState {
  theme: 'light' | 'dark';
  user: User | null;
}

type AppAction =
  | { type: 'SET_THEME'; payload: 'light' | 'dark' }
  | { type: 'SET_USER'; payload: User | null };

const AppContext = createContext<{
  state: AppState;
  dispatch: React.Dispatch<AppAction>;
} | null>(null);

function appReducer(state: AppState, action: AppAction): AppState {
  switch (action.type) {
    case 'SET_THEME':
      return { ...state, theme: action.payload };
    case 'SET_USER':
      return { ...state, user: action.payload };
    default:
      return state;
  }
}

function AppProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(appReducer, {
    theme: 'light',
    user: null,
  });

  return (
    <AppContext value={{ state, dispatch }}>
      {children}
    </AppContext>
  );
}

// 問題点: Context の値が変わると、useContext を使う全コンポーネントが再レンダリングされる
function UserName() {
  const { state } = useContext(AppContext)!;
  // theme が変わっても再レンダリングされてしまう
  return <span>{state.user?.name}</span>;
}

Context API vs Redux の使い分け:

  • Context API: テーマ、ロケール、認証状態など、変更頻度が低いグローバル設定
  • Redux: 頻繁に更新されるデータ、複雑な状態ロジック、DevTools が必要な場合

13.2 Zustand

// Zustand: 軽量でシンプルな状態管理
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

interface TodoStore {
  todos: Todo[];
  filter: 'all' | 'active' | 'completed';
  // アクション(状態と同じオブジェクトに定義)
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
  removeTodo: (id: string) => void;
  setFilter: (filter: 'all' | 'active' | 'completed') => void;
}

const useTodoStore = create<TodoStore>()(
  devtools(
    persist(
      immer((set) => ({
        todos: [],
        filter: 'all',

        addTodo: (text) =>
          set((state) => {
            state.todos.push({
              id: crypto.randomUUID(),
              text,
              completed: false,
            });
          }),

        toggleTodo: (id) =>
          set((state) => {
            const todo = state.todos.find((t) => t.id === id);
            if (todo) todo.completed = !todo.completed;
          }),

        removeTodo: (id) =>
          set((state) => {
            state.todos = state.todos.filter((t) => t.id !== id);
          }),

        setFilter: (filter) =>
          set((state) => {
            state.filter = filter;
          }),
      })),
      { name: 'todo-storage' }
    )
  )
);

// コンポーネントでの使用
function TodoList() {
  // 必要なフィールドのみ選択(自動的に最適化される)
  const todos = useTodoStore((state) => state.todos);
  const toggleTodo = useTodoStore((state) => state.toggleTodo);

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id} onClick={() => toggleTodo(todo.id)}>
          {todo.text}
        </li>
      ))}
    </ul>
  );
}

13.3 Jotai

// Jotai: アトミックな状態管理
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';

// プリミティブなアトム
const todosAtom = atom<Todo[]>([]);
const filterAtom = atom<'all' | 'active' | 'completed'>('all');

// 派生アトム(自動的にメモ化される)
const filteredTodosAtom = atom((get) => {
  const todos = get(todosAtom);
  const filter = get(filterAtom);

  switch (filter) {
    case 'active':
      return todos.filter((t) => !t.completed);
    case 'completed':
      return todos.filter((t) => t.completed);
    default:
      return todos;
  }
});

// 書き込み可能な派生アトム
const addTodoAtom = atom(null, (get, set, text: string) => {
  const todos = get(todosAtom);
  set(todosAtom, [
    ...todos,
    { id: crypto.randomUUID(), text, completed: false },
  ]);
});

// 非同期アトム
const fetchTodosAtom = atom(async () => {
  const response = await fetch('/api/todos');
  return response.json() as Promise<Todo[]>;
});

// コンポーネント
function TodoList() {
  const filteredTodos = useAtomValue(filteredTodosAtom);
  const [, addTodo] = useAtom(addTodoAtom);

  return (/* ... */);
}

13.4 MobX

// MobX: リアクティブな状態管理
import { makeAutoObservable, runInAction } from 'mobx';
import { observer } from 'mobx-react-lite';

class TodoStore {
  todos: Todo[] = [];
  filter: 'all' | 'active' | 'completed' = 'all';
  loading = false;

  constructor() {
    makeAutoObservable(this);
  }

  // computed(自動的に派生値を計算)
  get filteredTodos() {
    switch (this.filter) {
      case 'active':
        return this.todos.filter((t) => !t.completed);
      case 'completed':
        return this.todos.filter((t) => t.completed);
      default:
        return this.todos;
    }
  }

  get stats() {
    return {
      total: this.todos.length,
      completed: this.todos.filter((t) => t.completed).length,
    };
  }

  // アクション
  addTodo(text: string) {
    this.todos.push({
      id: crypto.randomUUID(),
      text,
      completed: false,
    });
  }

  toggleTodo(id: string) {
    const todo = this.todos.find((t) => t.id === id);
    if (todo) {
      todo.completed = !todo.completed;
    }
  }

  // 非同期アクション
  async fetchTodos() {
    this.loading = true;
    try {
      const response = await fetch('/api/todos');
      const todos = await response.json();
      runInAction(() => {
        this.todos = todos;
      });
    } finally {
      runInAction(() => {
        this.loading = false;
      });
    }
  }
}

const todoStore = new TodoStore();

// observer でラップしたコンポーネントは自動的に再レンダリングされる
const TodoList = observer(function TodoList() {
  return (
    <ul>
      {todoStore.filteredTodos.map((todo) => (
        <li key={todo.id} onClick={() => todoStore.toggleTodo(todo.id)}>
          {todo.text}
        </li>
      ))}
    </ul>
  );
});

13.5 比較表

特性Redux (RTK)Context APIZustandJotaiMobX
バンドルサイズ~11KB0KB(組込)~1KB~3KB~16KB
学習コスト中〜高
ボイラープレート中(RTK で大幅削減)非常に低非常に低
DevTools優秀無しRedux DevTools対応専用DevTools専用DevTools
ミドルウェア豊富無しミドルウェア対応無し無し
TypeScript優秀良好優秀優秀良好
データフェッチングRTK Query無し無し非同期atom無し
再レンダリング最適化セレクター難しい自動自動自動
SSR 対応良好組込良好良好要設定
エコシステム最大React内蔵成長中成長中大きい
状態の予測可能性非常に高い低い
パラダイムFlux / 関数型React 組込Flux 簡略版アトミックリアクティブ
大規模アプリ向け★★★★★★★☆☆☆★★★★☆★★★☆☆★★★★☆
小規模アプリ向け★★★☆☆★★★★★★★★★★★★★★★★★★★☆

13.6 どのライブラリを選ぶべきか

選択の指針:

小〜中規模アプリ(個人/小チーム)
├── グローバル状態が少ない → Context API で十分
├── 中程度の状態管理が必要 → Zustand(最小限の学習コスト)
└── ボトムアップな状態設計 → Jotai(アトミックアプローチ)

中〜大規模アプリ(チーム開発)
├── 複雑なビジネスロジック → Redux Toolkit
├── 豊富なミドルウェア/エコシステムが必要 → Redux Toolkit
├── 厳密な状態管理/監査ログが必要 → Redux Toolkit
├── データフェッチングの統合管理 → Redux Toolkit + RTK Query
└── OOP 指向のチーム → MobX

特殊なケース
├── 既存の Redux コードベースの保守 → Redux Toolkit に移行
├── リアルタイムアプリ(チャット等) → Redux + redux-saga
└── フォーム中心のアプリ → React Hook Form + Context API

14. 実践的なプロジェクト構成

14.1 Feature-Sliced Design

推奨ディレクトリ構成:

src/
├── app/                          # アプリケーションのエントリーポイント
│   ├── layout.tsx                # ルートレイアウト
│   ├── providers.tsx             # Store Provider
│   ├── page.tsx                  # トップページ
│   └── (routes)/                 # ルート定義
│       ├── todos/
│       │   └── page.tsx
│       ├── dashboard/
│       │   └── page.tsx
│       └── settings/
│           └── page.tsx
│
├── features/                     # 機能モジュール(Feature Slices)
│   ├── todos/
│   │   ├── todosSlice.ts         # Redux Slice(Reducer + Actions)
│   │   ├── todosSelectors.ts     # セレクター
│   │   ├── todosThunks.ts        # 非同期 Thunk
│   │   ├── todosApi.ts           # RTK Query エンドポイント
│   │   ├── types.ts              # 型定義
│   │   ├── components/           # Feature 固有のコンポーネント
│   │   │   ├── TodoList.tsx
│   │   │   ├── TodoItem.tsx
│   │   │   ├── TodoForm.tsx
│   │   │   └── TodoFilter.tsx
│   │   ├── hooks/                # Feature 固有のカスタムフック
│   │   │   ├── useTodos.ts
│   │   │   └── useTodoActions.ts
│   │   └── __tests__/            # テスト
│   │       ├── todosSlice.test.ts
│   │       ├── todosSelectors.test.ts
│   │       └── TodoList.test.tsx
│   │
│   ├── auth/
│   │   ├── authSlice.ts
│   │   ├── authSelectors.ts
│   │   ├── authApi.ts
│   │   ├── types.ts
│   │   ├── components/
│   │   │   ├── LoginForm.tsx
│   │   │   └── ProtectedRoute.tsx
│   │   └── hooks/
│   │       └── useAuth.ts
│   │
│   └── notifications/
│       ├── notificationsSlice.ts
│       ├── components/
│       │   └── NotificationToast.tsx
│       └── hooks/
│           └── useNotifications.ts
│
├── services/                     # API 定義
│   ├── api.ts                    # createApi のベース設定
│   └── types.ts                  # API 共通型
│
├── lib/                          # アプリケーション共通ユーティリティ
│   ├── store.ts                  # Store 設定
│   ├── hooks.ts                  # 型付き Redux フック
│   └── middleware/               # カスタムミドルウェア
│       ├── logger.ts
│       └── analytics.ts
│
├── components/                   # 共通 UI コンポーネント
│   ├── Button.tsx
│   ├── Modal.tsx
│   └── LoadingSpinner.tsx
│
├── styles/                       # グローバルスタイル
│   ├── globals.css
│   └── variables.css
│
└── types/                        # グローバル型定義
    └── index.ts

14.2 各ファイルの実装例

// lib/store.ts — Store の設定
import { configureStore } from '@reduxjs/toolkit';
import { apiSlice } from '../services/api';
import todosReducer from '../features/todos/todosSlice';
import authReducer from '../features/auth/authSlice';
import notificationsReducer from '../features/notifications/notificationsSlice';
import { listenerMiddleware } from './middleware/listeners';

export const makeStore = () =>
  configureStore({
    reducer: {
      todos: todosReducer,
      auth: authReducer,
      notifications: notificationsReducer,
      [apiSlice.reducerPath]: apiSlice.reducer,
    },
    middleware: (getDefault) =>
      getDefault()
        .prepend(listenerMiddleware.middleware)
        .concat(apiSlice.middleware),
  });

export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore['getState']>;
export type AppDispatch = AppStore['dispatch'];
// lib/hooks.ts — 型付きフック
import { useDispatch, useSelector, useStore } from 'react-redux';
import type { AppDispatch, RootState, AppStore } from './store';

export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppStore = useStore.withTypes<AppStore>();
// services/api.ts — API ベース設定
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import type { RootState } from '../lib/store';

export const apiSlice = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({
    baseUrl: process.env.NEXT_PUBLIC_API_URL || '/api',
    prepareHeaders: (headers, { getState }) => {
      const token = (getState() as RootState).auth.token;
      if (token) {
        headers.set('Authorization', `Bearer ${token}`);
      }
      return headers;
    },
  }),
  tagTypes: ['Todo', 'User'],
  endpoints: () => ({}), // 各 feature で injectEndpoints する
});
// features/todos/todosApi.ts — Feature 固有の API エンドポイント
import { apiSlice } from '../../services/api';
import type { Todo, CreateTodoRequest, UpdateTodoRequest } from './types';

export const todosApi = apiSlice.injectEndpoints({
  endpoints: (builder) => ({
    getTodos: builder.query<Todo[], { filter?: string }>({
      query: ({ filter } = {}) => ({
        url: '/todos',
        params: filter ? { filter } : undefined,
      }),
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Todo' as const, id })),
              { type: 'Todo', id: 'LIST' },
            ]
          : [{ type: 'Todo', id: 'LIST' }],
    }),

    addTodo: builder.mutation<Todo, CreateTodoRequest>({
      query: (body) => ({
        url: '/todos',
        method: 'POST',
        body,
      }),
      invalidatesTags: [{ type: 'Todo', id: 'LIST' }],
    }),

    updateTodo: builder.mutation<Todo, UpdateTodoRequest>({
      query: ({ id, ...body }) => ({
        url: `/todos/${id}`,
        method: 'PATCH',
        body,
      }),
      invalidatesTags: (result, error, { id }) => [
        { type: 'Todo', id },
        { type: 'Todo', id: 'LIST' },
      ],
    }),

    deleteTodo: builder.mutation<void, string>({
      query: (id) => ({
        url: `/todos/${id}`,
        method: 'DELETE',
      }),
      invalidatesTags: (result, error, id) => [
        { type: 'Todo', id },
        { type: 'Todo', id: 'LIST' },
      ],
    }),
  }),
});

export const {
  useGetTodosQuery,
  useAddTodoMutation,
  useUpdateTodoMutation,
  useDeleteTodoMutation,
} = todosApi;
// features/todos/hooks/useTodos.ts — カスタムフック
import { useCallback } from 'react';
import { useAppDispatch, useAppSelector } from '../../../lib/hooks';
import { selectFilteredTodos, selectTodoStatistics } from '../todosSelectors';
import { setFilter, toggleTodo, removeTodo } from '../todosSlice';
import { useGetTodosQuery, useAddTodoMutation } from '../todosApi';

export function useTodos() {
  const dispatch = useAppDispatch();
  const filteredTodos = useAppSelector(selectFilteredTodos);
  const stats = useAppSelector(selectTodoStatistics);
  const filter = useAppSelector((state) => state.todos.filter);

  const { isLoading, refetch } = useGetTodosQuery({});
  const [addTodo, { isLoading: isAdding }] = useAddTodoMutation();

  const handleToggle = useCallback(
    (id: string) => dispatch(toggleTodo(id)),
    [dispatch]
  );

  const handleRemove = useCallback(
    (id: string) => dispatch(removeTodo(id)),
    [dispatch]
  );

  const handleFilterChange = useCallback(
    (newFilter: 'all' | 'active' | 'completed') =>
      dispatch(setFilter(newFilter)),
    [dispatch]
  );

  const handleAdd = useCallback(
    async (text: string) => {
      await addTodo({ text, completed: false }).unwrap();
    },
    [addTodo]
  );

  return {
    todos: filteredTodos,
    stats,
    filter,
    isLoading,
    isAdding,
    toggleTodo: handleToggle,
    removeTodo: handleRemove,
    setFilter: handleFilterChange,
    addTodo: handleAdd,
    refetch,
  };
}

14.3 API エンドポイントの分割と統合

// services/api.ts(ベース)
export const apiSlice = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Todo', 'User', 'Notification'],
  endpoints: () => ({}),
});

// features/todos/todosApi.ts
const todosApi = apiSlice.injectEndpoints({
  endpoints: (builder) => ({
    getTodos: builder.query<Todo[], void>({ /* ... */ }),
    addTodo: builder.mutation<Todo, CreateTodoDto>({ /* ... */ }),
  }),
});

// features/auth/authApi.ts
const authApi = apiSlice.injectEndpoints({
  endpoints: (builder) => ({
    login: builder.mutation<AuthResponse, LoginRequest>({ /* ... */ }),
    getProfile: builder.query<User, void>({ /* ... */ }),
  }),
});

// features/notifications/notificationsApi.ts
const notificationsApi = apiSlice.injectEndpoints({
  endpoints: (builder) => ({
    getNotifications: builder.query<Notification[], void>({ /* ... */ }),
    markAsRead: builder.mutation<void, string>({ /* ... */ }),
  }),
});

// すべてのエンドポイントが1つの apiSlice.reducer と apiSlice.middleware で動作する

14.4 命名規則

// ファイル命名規則
// ├── camelCase: todosSlice.ts, todosSelectors.ts
// ├── PascalCase: TodoList.tsx, TodoItem.tsx(コンポーネント)
// └── kebab-case: todo-list.module.css(CSSモジュール)

// Slice 命名規則
const todosSlice = createSlice({
  name: 'todos',                    // ドメイン名(小文字)

  reducers: {
    todoAdded: ...,                 // 過去分詞(何が起きたか)
    todoToggled: ...,
    todoRemoved: ...,
    filterChanged: ...,
    searchQueryUpdated: ...,
  },
});

// セレクター命名規則
export const selectAllTodos = ...;          // select + 対象
export const selectTodoById = ...;          // select + 対象 + By条件
export const selectFilteredTodos = ...;     // select + 修飾語 + 対象
export const selectTodoStatistics = ...;    // select + 対象

// Thunk 命名規則
export const fetchTodos = ...;              // 動詞 + 対象
export const createTodo = ...;
export const updateTodoStatus = ...;

// 型命名規則
interface Todo { ... }                      // エンティティ
interface TodosState { ... }                // スライスの状態
interface CreateTodoRequest { ... }         // API リクエスト
interface TodosResponse { ... }             // API レスポンス
type TodoFilter = 'all' | 'active' | 'completed';  // ユニオン型

15. ベストプラクティスとアンチパターン

15.1 ベストプラクティス

必ず Redux Toolkit を使用する

// NG: レガシーな手書き Redux
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

const ADD_TODO = 'ADD_TODO';

function addTodo(text) {
  return { type: ADD_TODO, payload: { text } };
}

function todosReducer(state = [], action) {
  switch (action.type) {
    case ADD_TODO:
      return [...state, { id: Date.now(), text: action.payload.text, completed: false }];
    default:
      return state;
  }
}

const store = createStore(combineReducers({ todos: todosReducer }), applyMiddleware(thunk));

// OK: Redux Toolkit
import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit';

const todosSlice = createSlice({
  name: 'todos',
  initialState: [] as Todo[],
  reducers: {
    addTodo: {
      reducer: (state, action: PayloadAction<Todo>) => {
        state.push(action.payload);
      },
      prepare: (text: string) => ({
        payload: { id: crypto.randomUUID(), text, completed: false },
      }),
    },
  },
});

const store = configureStore({
  reducer: { todos: todosSlice.reducer },
});

状態を最小限に保つ

// NG: 派生データを状態に保存
interface BadState {
  todos: Todo[];
  completedTodos: Todo[];     // 派生データ(todosから計算可能)
  activeTodos: Todo[];        // 派生データ
  todoCount: number;          // 派生データ
  completedCount: number;     // 派生データ
}

// OK: 派生データはセレクターで計算
interface GoodState {
  todos: Todo[];
}

const selectCompletedTodos = createSelector(
  [(state: RootState) => state.todos],
  (todos) => todos.filter((t) => t.completed)
);

const selectTodoCount = createSelector(
  [(state: RootState) => state.todos],
  (todos) => todos.length
);

型付きフックを使用する

// NG: 毎回型を指定
function Component() {
  const todos = useSelector((state: RootState) => state.todos.items);
  const dispatch = useDispatch<AppDispatch>();
}

// OK: プロジェクト共通の型付きフック
// lib/hooks.ts で一度だけ定義
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();

function Component() {
  const todos = useAppSelector((state) => state.todos.items); // 型推論される
  const dispatch = useAppDispatch(); // AppDispatch 型
}

非同期ロジックは Slice の外に置く

// NG: コンポーネント内で直接 API を呼ぶ
function TodoList() {
  const dispatch = useAppDispatch();

  useEffect(() => {
    fetch('/api/todos')
      .then((res) => res.json())
      .then((data) => dispatch(setTodos(data)))
      .catch((err) => dispatch(setError(err.message)));
  }, [dispatch]);
}

// OK: createAsyncThunk を使用
const fetchTodos = createAsyncThunk('todos/fetch', async (_, { rejectWithValue }) => {
  try {
    const response = await fetch('/api/todos');
    return await response.json();
  } catch (error) {
    return rejectWithValue((error as Error).message);
  }
});

// さらに良い: RTK Query を使用
const todosApi = apiSlice.injectEndpoints({
  endpoints: (builder) => ({
    getTodos: builder.query<Todo[], void>({
      query: () => '/todos',
    }),
  }),
});

function TodoList() {
  const { data: todos, isLoading, error } = useGetTodosQuery();
  // ローディング、エラー処理、キャッシュが全て自動管理される
}

15.2 アンチパターン

Store にシリアライズ不可能な値を入れない

// NG: 関数、クラスインスタンス、Promise を状態に保存
interface BadState {
  callback: () => void;                // 関数
  date: Date;                          // Date オブジェクト
  regex: RegExp;                       // 正規表現
  map: Map<string, any>;              // Map
  set: Set<string>;                    // Set
  promise: Promise<any>;              // Promise
  error: Error;                        // Error オブジェクト
  domElement: HTMLElement;             // DOM 要素
}

// OK: プレーンなシリアライズ可能な値のみ
interface GoodState {
  callback: undefined;                 // 関数はストアに入れない
  date: string;                        // ISO 文字列
  pattern: string;                     // 文字列
  map: Record<string, any>;           // プレーンオブジェクト
  set: string[];                       // 配列
  error: { message: string; code?: number }; // プレーンオブジェクト
}

Reducer 内で副作用を実行しない

// NG: Reducer 内での副作用
const badSlice = createSlice({
  name: 'bad',
  initialState,
  reducers: {
    saveData: (state, action) => {
      state.data = action.payload;
      // 副作用!Reducer は純粋関数であるべき
      localStorage.setItem('data', JSON.stringify(action.payload));
      fetch('/api/save', { method: 'POST', body: JSON.stringify(action.payload) });
      console.log('データを保存しました');
    },
  },
});

// OK: リスナーミドルウェアで副作用を処理
const listenerMiddleware = createListenerMiddleware();

listenerMiddleware.startListening({
  actionCreator: dataSlice.actions.saveData,
  effect: async (action) => {
    localStorage.setItem('data', JSON.stringify(action.payload));
    await fetch('/api/save', {
      method: 'POST',
      body: JSON.stringify(action.payload),
    });
  },
});

dispatch チェーンの過度な使用を避ける

// NG: 連続する多数の dispatch
function handleSave() {
  dispatch(setLoading(true));
  dispatch(clearError());
  dispatch(setFormDirty(false));
  dispatch(setSaveTimestamp(Date.now()));
  dispatch(incrementSaveCount());
  dispatch(setLoading(false));
  dispatch(showNotification('保存しました'));
  // 7回の dispatch → 7回の状態更新(React 18 では自動バッチされるが非推奨)
}

// OK: 1つのアクションで複数の状態変更を行う
const formSlice = createSlice({
  name: 'form',
  initialState,
  reducers: {
    saveCompleted: (state, action: PayloadAction<{ timestamp: number }>) => {
      state.loading = false;
      state.error = null;
      state.isDirty = false;
      state.saveTimestamp = action.payload.timestamp;
      state.saveCount += 1;
    },
  },
});

// または Thunk でまとめる
function saveForm() {
  return async (dispatch: AppDispatch) => {
    dispatch(setSaving(true));
    try {
      await api.save(formData);
      dispatch(saveCompleted({ timestamp: Date.now() }));
      dispatch(showNotification('保存しました'));
    } catch (error) {
      dispatch(saveFailed((error as Error).message));
    }
  };
}

15.3 レガシー Redux からの移行ガイド

// ステップ1: configureStore に置き換える
// Before
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension';

const store = createStore(
  combineReducers({ todos: todosReducer, auth: authReducer }),
  composeWithDevTools(applyMiddleware(thunk))
);

// After
import { configureStore } from '@reduxjs/toolkit';

const store = configureStore({
  reducer: { todos: todosReducer, auth: authReducer },
  // thunk, devtools, immutabilityCheck が自動で含まれる
});

// ステップ2: createSlice で Reducer を書き換える(段階的に移行可能)
// 既存の switch-case Reducer はそのまま動作する
const store = configureStore({
  reducer: {
    todos: todosSliceReducer,      // 移行済み(createSlice)
    auth: legacyAuthReducer,       // 未移行(switch-case)
  },
});

// ステップ3: connect を Hooks に置き換える
// Before
const mapStateToProps = (state) => ({ todos: state.todos });
const mapDispatchToProps = { addTodo, toggleTodo };
export default connect(mapStateToProps, mapDispatchToProps)(TodoList);

// After
function TodoList() {
  const todos = useAppSelector((state) => state.todos.items);
  const dispatch = useAppDispatch();
  // ...
}
export default TodoList;

// ステップ4: 手書きの Thunk を createAsyncThunk に移行
// Before
function fetchTodos() {
  return async (dispatch) => {
    dispatch({ type: 'FETCH_TODOS_REQUEST' });
    try {
      const response = await fetch('/api/todos');
      const data = await response.json();
      dispatch({ type: 'FETCH_TODOS_SUCCESS', payload: data });
    } catch (error) {
      dispatch({ type: 'FETCH_TODOS_FAILURE', payload: error.message });
    }
  };
}

// After
const fetchTodos = createAsyncThunk('todos/fetch', async (_, { rejectWithValue }) => {
  try {
    const response = await fetch('/api/todos');
    return await response.json();
  } catch (error) {
    return rejectWithValue((error as Error).message);
  }
});

// ステップ5: データフェッチングを RTK Query に移行(最も大きな改善)

15.4 よくある間違い

// 間違い1: useSelector 内で毎回新しいオブジェクトを作成する
// → 無限再レンダリングの原因になる
const bad = useAppSelector((state) => ({
  a: state.a,
  b: state.b,
}));

// 間違い2: dispatch を依存配列に入れ忘れる(ESLint が警告する)
const dispatch = useAppDispatch();
const handleClick = useCallback(() => {
  dispatch(someAction());
}, []); // dispatch が抜けている

// 間違い3: 状態を直接変更する(createSlice 外では NG)
const reducer = (state, action) => {
  state.value = action.payload; // createSlice の外では禁止!
  return state;
};

// 間違い4: Reducer で undefined を返す
const reducer = (state, action) => {
  if (action.type === 'RESET') {
    return undefined; // NG: undefined は初期化トリガーだが混乱の元
  }
  return state;
};
// OK: 明示的に初期状態を返す
const reducer = (state, action) => {
  if (action.type === 'RESET') {
    return initialState;
  }
  return state;
};

16. まとめ

16.1 Redux を使うべきケース

Redux は強力なツールだが、すべてのプロジェクトに必要なわけではない。以下の条件を満たす場合に Redux の導入を検討すべきである。

Redux が適している場面:

✅ アプリケーション全体で共有される大量の状態がある
✅ 状態の更新ロジックが複雑である
✅ コードベースが中〜大規模で、多くの開発者が関わる
✅ 状態の変更履歴を追跡・監査する必要がある
✅ タイムトラベルデバッグが有用な場面がある
✅ サーバーサイドレンダリング(SSR)で状態の引き渡しが必要
✅ データフェッチング・キャッシングの統合管理が必要(RTK Query)
✅ テストの容易さが重要

Redux が不要な場面:

❌ 小規模なアプリケーション(Context API で十分)
❌ グローバルな状態がほとんどない
❌ プロトタイプや短期的なプロジェクト
❌ 状態の変更パターンが単純

16.2 モダン Redux の全体像

モダン Redux エコシステム(2024年〜):

Redux Toolkit (RTK) — 唯一の公式推奨アプローチ
├── configureStore — Store の設定
├── createSlice — Reducer + Actions の定義
├── createAsyncThunk — 非同期ロジック
├── createEntityAdapter — 正規化された CRUD
├── createSelector — メモ化されたセレクター
├── createListenerMiddleware — 副作用管理
└── RTK Query — データフェッチング & キャッシング
    ├── createApi — API 定義
    ├── fetchBaseQuery — HTTP クライアント
    ├── 自動キャッシュ管理
    ├── 楽観的更新
    ├── ポーリング
    └── コード生成(OpenAPI 対応)

React-Redux 9.x — React バインディング
├── Provider — Store の提供
├── useSelector — 状態の購読
├── useDispatch — アクションの発行
└── useStore — Store への直接アクセス

開発ツール
├── Redux DevTools — ブラウザ拡張
├── RTK Query DevTools — キャッシュ状態の可視化
└── ESLint Plugin — ベストプラクティスの強制

16.3 学習ロードマップ

Redux 学習の推奨順序:

Phase 1: 基礎(1〜2日)
├── Redux の三原則を理解する
├── Action, Reducer, Store の概念を把握する
└── 単方向データフローを理解する

Phase 2: Redux Toolkit(2〜3日)
├── configureStore でストアを作成する
├── createSlice で Reducer を書く
├── useSelector / useDispatch を使う
└── createAsyncThunk で非同期処理を行う

Phase 3: 実践(1週間)
├── Feature-Sliced な構成でアプリを作る
├── createSelector でパフォーマンスを最適化する
├── テストを書く
└── Redux DevTools を使いこなす

Phase 4: 高度な機能(必要に応じて)
├── RTK Query でデータフェッチングを管理する
├── createEntityAdapter で正規化を行う
├── createListenerMiddleware で副作用を管理する
└── コード分割と動的 Reducer 注入を実装する

16.4 将来の展望

Redux エコシステムは、以下の方向で進化を続けている。

  1. RTK Query の強化: WebSocket のネイティブサポート、ストリーミングクエリの改善、より高度なキャッシュ戦略の提供が進んでいる。

  2. TypeScript ファースト: Redux Toolkit 2.0 以降、TypeScript の型推論がさらに改善され、手動での型定義がますます不要になっている。

  3. React Server Components との統合: Next.js App Router など、Server Components との統合パターンが整備されつつある。

  4. パフォーマンスの向上: Immer の最適化、セレクターのメモ化戦略の改善、バンドルサイズの削減が継続的に行われている。

  5. コード生成: OpenAPI 仕様からの RTK Query エンドポイントの自動生成ツールが充実しつつある。

Redux は10年近い歴史の中で、JavaScript の状態管理における事実上の標準としての地位を確立した。Redux Toolkit の登場により、かつて批判されていたボイラープレートの多さは大幅に解消され、モダンな開発体験を提供している。

適切な場面で Redux を選択し、RTK のベストプラクティスに従うことで、予測可能でデバッグしやすく、スケーラブルなアプリケーションを構築できる。


参考文献