React: 複合状態:useReducer

最終更新:2026-10-26

1. 学習内容



2. ショッピングカートの状態管理に関する話

(1) 課題:複数の useState インスタンス間の複雑な相互作用

アリスは、複数のインタラクションを処理する必要があるショッピングカートコンポーネントを開発しています:

JSX
function ShoppingCart() {
  const [items, setItems] = useState([])
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState(null)
  const [coupon, setCoupon] = useState(null)

  // Add Item
  function addItem(product) {
    const existing = items.find(i => i.id === product.id)
    if (existing) {
      // ❌ Several setState The call order depends on the current value
      setItems(prev => prev.map(i =>
        i.id === product.id ? { ...i, qty: i.qty + 1 } : i
      ))
    } else {
      setItems(prev => [...prev, { ...product, qty: 1 }])
    }
  }

  // Apply a Coupon
  function applyCoupon(code) {
    setIsLoading(true)
    fetch('/api/coupon/' + code)
      .then(res => res.json())
      .then(data => {
        if (data.valid) {
          setCoupon(data)
          setError(null)
        } else {
          setError('Coupon is invalid')
        }
      })
      .catch(err => setError(err.message))
      .finally(() => setIsLoading(false))
  }

  // Question:items、isLoading、error、coupon The four states are interrelated
  // One operation (e.g. applyCoupon) triggers 3 setState calls
  // It's easy to miss an update to a particular state,Causes inconsistencies
}
▶ 試してみよう

(2) useReducer ソリューション

JSX
// 1. Define the Initial State (seeded with 1 product so the cart is visible)
const initialState = {
  items: [
    { id: 1, name: 'React 101', qty: 1, price: 59 }
  ],
  isLoading: false,
  error: null,
  coupon: null,
  total: 59
}

// 2. Definition reducer(Pure Functions:Receive Previous State + action → Return to the new state)
function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find(i => i.id === action.product.id)
      const newItems = existing
        ? state.items.map(i =>
            i.id === action.product.id ? { ...i, qty: i.qty + 1 } : i
          )
        : [...state.items, { ...action.product, qty: 1 }]
      return { ...state, items: newItems, error: null }
    }
    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter(i => i.id !== action.id)
      }
    case 'UPDATE_QTY':
      return {
        ...state,
        items: state.items.map(i =>
          i.id === action.id ? { ...i, qty: Math.max(1, action.qty) } : i
        )
      }
    case 'APPLY_COUPON_REQUEST':
      return { ...state, isLoading: true, error: null }
    case 'APPLY_COUPON_SUCCESS':
      return { ...state, isLoading: false, coupon: action.coupon }
    case 'APPLY_COUPON_FAILURE':
      return { ...state, isLoading: false, error: action.error }
    case 'CLEAR_ERROR':
      return { ...state, error: null }
    case 'RESET_CART':
      return initialState
    default:
      return state
  }
}

// 3. Using in a component
function ShoppingCart() {
  const [state, dispatch] = useReducer(cartReducer, initialState)
  const [code, setCode] = useState('')

  function handleAdd(product) {
    dispatch({ type: 'ADD_ITEM', product })
  }

  // Simulated async request for the demo (swap in a real fetch in a real app)
  function handleApplyCoupon() {
    dispatch({ type: 'APPLY_COUPON_REQUEST' })
    setTimeout(() => {
      if (code.trim().toUpperCase() === 'SAVE10') {
        dispatch({ type: 'APPLY_COUPON_SUCCESS', coupon: { code: code.trim().toUpperCase() } })
      } else {
        dispatch({ type: 'APPLY_COUPON_FAILURE', error: 'Coupon is invalid (try SAVE10)' })
      }
    }, 400)
  }

  return (
    <div>
      <p>Items in cart:{state.items.reduce((n, i) => n + i.qty, 0)}</p>
      {state.items.map(item => (
        <div key={item.id} style={{ marginBottom: 6 }}>
          {item.name} × {item.qty}
          <button onClick={() => dispatch({ type: 'UPDATE_QTY', id: item.id, qty: item.qty + 1 })}>+</button>
          <button onClick={() => dispatch({ type: 'REMOVE_ITEM', id: item.id })}>Delete</button>
        </div>
      ))}
      <button onClick={() => handleAdd({ id: Date.now(), name: 'React in Action', qty: 1, price: 79 })}>
        ➕ Add React in Action
      </button>
      <div style={{ marginTop: 12 }}>
        <input
          value={code}
          onChange={e => setCode(e.target.value)}
          placeholder="Enter coupon code"
          style={{ padding: 6 }}
        />
        <button onClick={handleApplyCoupon}>Apply coupon</button>
      </div>
      {state.isLoading && <p>Processing......</p>}
      {state.error && <p style={{ color: 'red' }}>{state.error}</p>}
      {state.coupon && <p>Coupon has been applied:{state.coupon.code}</p>}
    </div>
  )
}
▶ 試してみよう

メリット:すべての状態変化は単一の reducer 関数に集約されており、予測可能で、テストしやすく、デバッグも容易です。各アクションは「何が起こったか」の記録となります。



3. useReducerのコアパターン

概念 説明 例
reducer 純粋関数, (状態, アクション) → 新しい状態 function cartReducer(state, action)
action 「何が起きたか」を記述するオブジェクト { type: 'ADD_ITEM', payload: product }
dispatch アクションを送信する関数 dispatch({ type: 'INCREMENT' })
initialState 初期状態オブジェクト { items: [], total: 0 }
init 関数 遅延初期化(オプションの3番目の引数) useReducer(reducer, initialArg, init)
100%
graph LR
    A[Components] -->|dispatch(action)| B[reducer]
    B -->|(state, action) => newState| C[New Status]
    C -->|Update| A
    D[User Actions] -->|Trigger| A
    
    style B fill:#1890ff,color:#fff
    style C fill:#52c41a,color:#fff

(1) 基本的な構文

JSX
const [state, dispatch] = useReducer(reducer, initialState)

// reducer It is a pure function:
function reducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 }
    case 'SET_NAME':
      return { ...state, name: action.payload }
    default:
      return state
  }
}
▶ 試してみよう

出力:

TEXT
横並びの対比:HTML 構文と JSX 構文が同等のマークアップを示す

(2) アクション設計の原則

原則 説明 悪い例 良い例
タイプ名には過去形を使う 「すでに起こったこと」を表す ADD ITEM_ADDED
データの持ち込みを最小限に抑える 必要なデータのみを持ち込み、残りはリデューサーで計算する dispatch({ type: 'SET_TOTAL', total: 42 }) dispatch({ type: 'ITEM_ADDED', item })。ここで、totalはリデューサーで計算される
副作用を扱わないこと リデューサーは純粋関数でなければならない(APIを呼び出したり、localStorageの読み書きを行ったりしてはならない) case 'LOGIN': fetch(...) APIの呼び出しはuseEffectまたはイベントハンドラ内で行い、完了後にディスパッチする


4. useReducer 対 useState

ディメンション useState useReducer
状態の数 3以下 3より多い、または状態が相互に関連している
更新ロジック 単純(トグル、+1) 複雑(互いに影響し合う複数のサブ値)
読みやすさ 良好(一目で理解しやすい) 良好(一元管理)
テスト可能性 レンダリングが必要なコンポーネントはテストしなければならない リデューサーは純粋関数であるため、直接テストできる
適した規模 小さなコンポーネント、単体の状態 中規模から大規模のコンポーネント、複雑なフォーム、ショッピングカート

▶ サンプル:同じ機能の異なる実装の比較

▶ 試してみよう

出力:

TEXT
Count: 0 | Last Action: none | Operations: 0。+1→INCREMENT、-1→DECREMENT、Reset→initial、Set 100。History はすべての操作を記録する。


5. useReducer + Context:軽量なグローバル状態

複数のコンポーネントで同じリデューサーの状態を共有する必要がある場合は、Context を使用して dispatch を渡します:

▶ 実行例を展開(86 行)
▶ 試してみよう

6. 完全な例:多段階フォーム(ウィザード)

▶ 実行例を展開(194 行)
▶ 試してみよう

出力:

TEXT
表示:"Reset"。状態:count(setter:setCount)。ボタン:setCount(c => c + 1)}>+1、setCount(0)}>Reset、dispatch({ type: 'INCREMENT' })}>+1、dispatch({ type: 'DECREMENT' })}>-1。useReducer で複雑な状態を管理

期待される出力:3段階の登録フォーム(アカウント → プロフィール → 設定)。各段階でバリデーションが行われ、前後のステップへ移動できる機能を備え、最終的な送信後に送信結果が表示されるもの。


▶ サンプル:非同期データ読み込みと useReducer

▶ 実行例を展開(117 行)
▶ 試してみよう

出力:

TEXT
User 1/2/3 をクリック → ⏳ Loading... → 👤 カード(User 1、user1@example.com、Administrator、2026-01-15)。"Trigger error" → ❌ "Invalid User ID"

出力:

TEXT
Undo/Redo エディタ:テキストを入力 → Save。Undo で取り消し、Redo でやり直し。過去/未来の状態スタックを表示

▶ サンプル:useReducer を使用した「元に戻す/やり直し」機能の実装

▶ 試してみよう

出力:

TEXT
入力 → Save → "Current: hello"。Undo → 前のステップに戻る。Redo → 復元。表示:Past: [v1, v2] | Future: [v4]

出力:

TEXT
useReducer で実装した登録フォーム:username、email、password、role、agree チェックボックス。送信時に各フィールドを検証。Reset で全内容をクリア

▶ サンプル:useReducer を使用したフォーム状態の管理

▶ 試してみよう

出力:

TEXT
useReducer で実装した登録フォーム(Username、Email、Password、Agree チェックボックス)。無効な送信 → 赤いエラーメッセージ。Reset → 全フィールドをクリア。

出力:

TEXT
ショッピングカートの context:"Add" ボタン付きの商品リスト。カートは商品と合計を表示。"Clear" でカートを空にする

▶ サンプル:グローバルなショッピングカートにおける useReducer と Context の活用

▶ 実行例を展開(62 行)
▶ 試してみよう

出力:

TEXT
商品(Keyboard $79、Mouse $49、Monitor $399)に "Add" ボタン。カート:商品 × 数量 + 合計。"Clear" でカートを空にする。Context によるグローバル状態。

❓ よくある質問

Q なぜリデューサーは純粋関数でなければならないのですか?
A 純粋関数とは、同じ入力に対して同じ出力を返し、外部変数を変更せず、副作用がない(APIを呼び出したり、ファイルの読み書きを行ったりしない)関数のことを指します。Reactは、パフォーマンスの最適化(不要なレンダリングのスキップ)やデバッグ(タイムトラベル)のために、リデューサーの予測可能性に依存しています。リデューサー内でAPIを呼び出すと、予測不可能な状態変化を引き起こす可能性があり、デバッグ作業の妨げとなります。
Q useState から useReducer に切り替えるべきなのはいつですか?
A 以下の3つの状況のいずれかが発生した場合です: ① 1つのアクションで3つ以上のuseStateインスタンスを更新する必要がある場合(例:ボタンをクリックすると、isLoading、error、dataが同時に更新される場合); ② 状態ロジックが複雑すぎて、複数のイベントハンドラ関数に分散しており、追跡が困難な場合; ③ コンポーネントに、互いに依存し合う複数の状態のサブ値がある場合。切り替えのタイミング:useStateの実装により、コンポーネントのコード行数が100行を超えるようになったとき。
Q リデューサーに副作用(fetchリクエストなど)を含めることはできますか?
A 絶対にできません。リデューサーは純粋関数でなければなりません。つまり、同じ入力に対しては常に同じ出力を返し、API呼び出し、DOM操作、乱数生成などの副作用があってはなりません。副作用はuseEffectやイベントハンドラ関数に記述する必要があります。リデューサーに副作用を記述すると、状態が予測不能になったり、デバッグが困難になったり、タイムトラベル(DevToolsのリプレイ機能)が機能しなくなったりする原因となります。

📖 まとめ


📝 練習問題

  1. 基本演習(難易度 ⭐):Counter コンポーネントを作成し、useReducer を使用して、+1、-1、+10、リセットの 4 つの演算を実装してください。ページ上に現在の値と演算履歴(直近 5 回の演算)を表示してください。
  2. 上級演習(難易度 ⭐⭐):TodoApp コンポーネントを作成し、useReducer および Context を使用して、追加、完了マークの付け、編集、削除、完了項目のクリアという 5 つの機能を実装してください。
  3. 課題(難易度:⭐⭐⭐):UndoRedoList コンポーネントを作成し、useReducer を使用して「元に戻す/やり直し」機能を実装してください。各操作で履歴のスナップショットを保存し、Ctrl+Z で「元に戻す」、Ctrl+Shift+Z で「やり直し」が実行できるようにしてください。
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%