React: 複合状態:useReducer
最終更新:2026-10-26
1. 学習内容
- useReducer の基本的なパターン(state + action → newState)
- アクションタイプの設計原則
- useState との比較(どちらをいつ使うか)
- useReducer と Context の組み合わせ(軽量版 Redux)
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) |
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のリプレイ機能)が機能しなくなったりする原因となります。📖 まとめ
useReducer(reducer, initialState)戻る[state, dispatch]- レデューサーは純粋関数である:
(state, action) => newState - action には
type(発生した事象の説明)と、オプションのpayload(データ)が含まれます useStateは単純で独立した状態に適しており、useReducerは複雑で相互依存的な状態に適していますuseReducer + Context小規模なプロジェクトでは、Reduxの代替として使用可能
📝 練習問題
- 基本演習(難易度 ⭐):
Counterコンポーネントを作成し、useReducerを使用して、+1、-1、+10、リセットの 4 つの演算を実装してください。ページ上に現在の値と演算履歴(直近 5 回の演算)を表示してください。 - 上級演習(難易度 ⭐⭐):
TodoAppコンポーネントを作成し、useReducerおよびContextを使用して、追加、完了マークの付け、編集、削除、完了項目のクリアという 5 つの機能を実装してください。 - 課題(難易度:⭐⭐⭐):
UndoRedoListコンポーネントを作成し、useReducerを使用して「元に戻す/やり直し」機能を実装してください。各操作で履歴のスナップショットを保存し、Ctrl+Z で「元に戻す」、Ctrl+Shift+Z で「やり直し」が実行できるようにしてください。