C
React/Hooks/Lesson 10

useReducer — useStateが限界に達したとき、Reduxの基盤

35分·theory
このチャプター
2/5
TypeScript

useReducer — useStateが限界に達したとき、Reduxの基盤

💡 なぜ学ぶべきか?— useStateが煩雑になる瞬間がある

🎯 5〜6個の `useState` が1つのコンポーネントに積み重なり、互いに影響し合う瞬間 — 状態更新のコードは触れるたびに壊れやすくなっていきます。
💼 `useReducer` は、そうした状態を**単一オブジェクト + dispatch(action) パターン**にまとめます。どのアクションがどの変更を生み出すかがすべて reducer 関数に記述されており、追跡が容易です。
Redux Toolkit・Zustand・`useActionState`(React 19)— いずれも `useReducer` と同じ考え方に基づいています。このフックを理解することが、それらへの自然な入り口になります。
🔗 面接の定番質問: 「`useState` と `useReducer` の使い分けの基準は?」— 状態の数・複雑さ・複数箇所からの更新有無が判断のポイントです。
🏢 실무에서는
ショッピングカート(商品追加・数量変更・削除・全削除)、フォームウィザード(次へ・戻る・保存・検証)、コラボレーティブキャンバス(図形追加・移動・コピー・undo)— これらはすべて `useReducer` の守備範囲です。アクションの種類が増えても、reducer の1か所に分岐を追加するだけで対応できます。

reducer · action · dispatch — 3つの概念

1. シグネチャ

ts
const [state, dispatch] = useReducer(reducer, initialState);
  • reducer: (state, action) => newState という形の純粋関数
  • dispatch(action): reducerを呼び出してstateを更新する

2. Discriminated Unionでactionの型を定義する

ts
type State = { items: CartItem[] };
type Action =
  | { type: 'add'; item: CartItem }
  | { type: 'remove'; id: number }
  | { type: 'setQty'; id: number; qty: number }
  | { type: 'clear' };

typeフィールド(タグ)で分岐するunion。switch文の中で、各caseごとにactionの追加フィールドが正確に推論されます。

3. reducer — 純粋関数、ミュートは絶対禁止

ts
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'add':
      return { items: [...state.items, action.item] };
    case 'remove':
      return { items: state.items.filter(i => i.id !== action.id) };
    case 'clear':
      return { items: [] };
    default: {
      const _: never = action; // exhaustive check
      return state;
    }
  }
}

4. useStateとuseReducerの選択基準

状況推奨
独立した1〜2個の単純な値useState
オブジェクト内の複数フィールドが一緒に変わるuseReducer
更新ロジックが5行以上useReducer
複数の箇所から同じアクションをdispatchするuseReducer
Undo/Redoが必要useReducer
💻 🅰️ useState — 更新ロジックが散在する
// ❌ useState のみ — ハンドラーごとに個別に
import { useState } from 'react';

type CartItem = { id: number; name: string; qty: number };

export function Cart() {
  const [items, setItems] = useState<CartItem[]>([]);

  const add = (item: CartItem) => setItems([...items, item]);
  const remove = (id: number) => setItems(items.filter(i => i.id !== id));
  const setQty = (id: number, qty: number) =>
    setItems(items.map(i => i.id === id ? { ...i, qty } : i));
  const clear = () => setItems([]);

  // フィルター・ソート・合計などを追加すると、また別の useState
  // → state が増えるほどコンポーネントが汚くなる

  return <div>{/* ... */}</div>;
}
💻 🅱️ useReducer — reducerに一元化
// ✅ useReducer — すべてのアクションが reducer の一箇所に
import { useReducer } from 'react';

type CartItem = { id: number; name: string; qty: number };
type State = { items: CartItem[]; filter: 'all' | 'active' };

type Action =
  | { type: 'add'; item: CartItem }
  | { type: 'remove'; id: number }
  | { type: 'setQty'; id: number; qty: number }
  | { type: 'setFilter'; filter: 'all' | 'active' }
  | { type: 'clear' };

const initial: State = { items: [], filter: 'all' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'add':
      return { ...state, items: [...state.items, action.item] };
    case 'remove':
      return { ...state, items: state.items.filter(i => i.id !== action.id) };
    case 'setQty':
      return {
        ...state,
        items: state.items.map(i =>
          i.id === action.id ? { ...i, qty: action.qty } : i
        ),
      };
    case 'setFilter':
      return { ...state, filter: action.filter };
    case 'clear':
      return { ...state, items: [] };
    default: {
      const _: never = action; // exhaustive
      return state;
    }
  }
}

export function Cart() {
  const [state, dispatch] = useReducer(reducer, initial);

  const visible = state.filter === 'all'
    ? state.items
    : state.items.filter(i => i.qty > 0);

  return (
    <div>
      <button onClick={() => dispatch({ type: 'add', item: { id: Date.now(), name: '本', qty: 1 } })}>
        本 追加
      </button>
      <button onClick={() => dispatch({ type: 'clear' })}>
        すべてクリア
      </button>
      <ul>
        {visible.map(i => (
          <li key={i.id}>
            {i.name} ×
            <input
              type="number"
              value={i.qty}
              onChange={(e) => dispatch({ type: 'setQty', id: i.id, qty: Number(e.target.value) })}
            />
            <button onClick={() => dispatch({ type: 'remove', id: i.id })}>削除</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

// 利点:
// - 新しいアクション = case 1つ追加 (一箇所に集中)
// - reducer のみ単体テスト可能 (純粋関数)
// - Redux Toolkit · useActionState へ自然に拡張

💡 💡 useReducer実践 5選

1. reducerは必ず純粋関数にする
同じ(state, action)であれば常に同じ結果を返すこと。fetchやsetTimeoutのような副作用は禁止。

2. stateのミュートは絶対禁止

ts
// ❌ state.items.push(item); return state;
// ✅ return { ...state, items: [...state.items, item] };

3. Discriminated Unionでactionの型を定義する
switch文の中で、caseごとにactionの各フィールドが正確に推論される。

4. neverでexhaustiveチェック

ts
default: { const _: never = action; return state; }

Actionに新しいtypeを追加してcaseを書き忘れると、コンパイルエラーで知らせてくれる。

5. Context + useReducer = ミニRedux
小規模なアプリではReduxを使わなくてもこれで十分。

⚡ 実際に試してみよう — reducerパターン

ショッピングカートのreducerにおけるアクション処理をシミュレーションします。
✏️ JS 코드
📟 コンソール出力
▶ 実行ボタンを押してください
⚠️ ブラウザのサンドボックスで実行 — console.log()のみ対応、alert/fetchは不可

確認クイズ

useReducerがuseStateより適している状況はどれですか?
💡 `useReducer` の真価は、**複数のアクションが同じ state をさまざまな方法で更新する**場合に発揮されます。更新ロジックが reducer の1か所に集約されるため、追跡・テストが容易になります。単純なトグルや1つの入力値には、`useState` の方が軽量です。
先に読むとよい概念: useEffect
useReducer — Redux の基盤 - React