複合状態:useReducer
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
const initialState = {
items: [],
isLoading: false,
error: null,
coupon: null,
total: 0
}
// 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)
function handleAdd(product) {
dispatch({ type: 'ADD_ITEM', product })
}
function handleApplyCoupon(code) {
dispatch({ type: 'APPLY_COUPON_REQUEST' })
fetch('/api/coupon/' + code)
.then(res => res.json())
.then(data => {
if (data.valid) {
dispatch({ type: 'APPLY_COUPON_SUCCESS', coupon: data })
} else {
dispatch({ type: 'APPLY_COUPON_FAILURE', error: 'Coupon is invalid' })
}
})
.catch(err => dispatch({ type: 'APPLY_COUPON_FAILURE', error: err.message }))
}
return (
<div>
{state.items.map(item => (
<div key={item.id}>
{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>
))}
{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
}
}
(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) | 複雑(互いに影響し合う複数のサブ値) |
| 読みやすさ | 良好(一目で理解しやすい) | 良好(一元管理) |
| テスト可能性 | レンダリングが必要なコンポーネントはテストしなければならない | リデューサーは純粋関数であるため、直接テストできる |
| 適した規模 | 小さなコンポーネント、単体の状態 | 中規模から大規模のコンポーネント、複雑なフォーム、ショッピングカート |
(1) ▶ サンプル:同じ機能の異なる実装の比較
JSX
// ============================================
// Example:Counter——useState vs useReducer
// ============================================
// ---- useState version(Simple Scenarios)----
function CounterWithState() {
const [count, setCount] = useState(0)
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
)
}
// ---- useReducer version(Complex Scenarios)----
const initialState = { count: 0, lastAction: null, history: [] }
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return {
count: state.count + 1,
lastAction: 'INCREMENT',
history: [...state.history, 'INCREMENT']
}
case 'DECREMENT':
return {
count: state.count - 1,
lastAction: 'DECREMENT',
history: [...state.history, 'DECREMENT']
}
case 'RESET':
return initialState
case 'SET':
return { ...state, count: action.value }
default:
return state
}
}
function CounterWithReducer() {
const [state, dispatch] = useReducer(counterReducer, initialState)
return (
<div>
<p>Count:{state.count}</p>
<p>Last Operation:{state.lastAction}</p>
<p>Number of operations:{state.history.length}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+1</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-1</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
<button onClick={() => dispatch({ type: 'SET', value: 100 })}>Set as 100</button>
</div>
)
}
5. useReducer + Context:軽量なグローバル状態
複数のコンポーネントで同じリデューサーの状態を共有する必要がある場合は、Context を使用して dispatch を渡します:
JSX
// ============================================
// Example:Todo Applications(useReducer + Context)
// Features:Global State Management,Any child component can dispatch
// ============================================
// ---- 1. Create Context ----
const TodoContext = React.createContext(null)
// ---- 2. Definition reducer ----
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return [...state, { id: Date.now(), text: action.text, done: false }]
case 'TOGGLE_TODO':
return state.map(t =>
t.id === action.id ? { ...t, done: !t.done } : t
)
case 'DELETE_TODO':
return state.filter(t => t.id !== action.id)
case 'CLEAR_DONE':
return state.filter(t => !t.done)
default:
return state
}
}
// ---- 3. Provider Package ----
function TodoProvider({ children }) {
const [todos, dispatch] = useReducer(todoReducer, [
{ id: 1, text: 'Study useReducer', done: true },
{ id: 2, text: 'Understanding Context Usage', done: false }
])
return (
<TodoContext.Provider value={{ todos, dispatch }}>
{children}
</TodoContext.Provider>
)
}
// ---- 4. Reading from Any Subcomponent/Edit Status ----
function AddTodo() {
const [text, setText] = useState('')
const { dispatch } = useContext(TodoContext)
function handleSubmit(e) {
e.preventDefault()
if (!text.trim()) return
dispatch({ type: 'ADD_TODO', text: text.trim() })
setText('')
}
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={e => setText(e.target.value)} placeholder="Add to To-Do List..." />
<button type="submit">Add</button>
</form>
)
}
function TodoList() {
const { todos, dispatch } = useContext(TodoContext)
return (
<ul>
{todos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
<input type="checkbox" checked={todo.done}
onChange={() => dispatch({ type: 'TOGGLE_TODO', id: todo.id })} />
{todo.text}
<button onClick={() => dispatch({ type: 'DELETE_TODO', id: todo.id })}>✕</button>
</li>
))}
</ul>
)
}
function TodoStats() {
const { todos } = useContext(TodoContext)
const done = todos.filter(t => t.done).length
const pending = todos.length - done
return (
<p style={{ fontSize: '13px', color: '#666' }}>
Total {todos.length} | Completed {done} | Pending completion {pending}
{done > 0 && (
<button onClick={() => dispatch({ type: 'CLEAR_DONE' })} style={{ marginLeft: '8px' }}>
Clear Completed
</button>
)}
</p>
)
}
// ---- 5. Top-level usage ----
function App() {
return (
<TodoProvider>
<div style={{ maxWidth: '500px', margin: '0 auto' }}>
<h2>📋 Todo(useReducer + Context)</h2>
<AddTodo />
<TodoStats />
<TodoList />
</div>
</TodoProvider>
)
}
6. 完全な例:多段階フォーム(ウィザード)
JSX
// ============================================
// Complete Example:Multi-Step Registration Form(Wizard)
// Features:useReducer Managing the Complex State of Multi-Step Forms
// ============================================
const wizardInitialState = {
currentStep: 1,
formData: {
account: { username: '', email: '', password: '' },
profile: { name: '', bio: '', avatar: '' },
preferences: { language: 'zh', theme: 'light', newsletter: false }
},
errors: {},
isSubmitting: false,
completed: false
}
function wizardReducer(state, action) {
switch (action.type) {
case 'NEXT_STEP':
if (state.currentStep >= 3) return state
return { ...state, currentStep: state.currentStep + 1, errors: {} }
case 'PREV_STEP':
if (state.currentStep <= 1) return state
return { ...state, currentStep: state.currentStep - 1 }
case 'UPDATE_FIELD':
return {
...state,
formData: {
...state.formData,
[action.section]: {
...state.formData[action.section],
[action.field]: action.value
}
}
}
case 'SET_ERRORS':
return { ...state, errors: action.errors }
case 'SUBMIT_START':
return { ...state, isSubmitting: true, errors: {} }
case 'SUBMIT_SUCCESS':
return { ...state, isSubmitting: false, completed: true }
case 'SUBMIT_FAILURE':
return { ...state, isSubmitting: false, errors: { submit: action.error } }
case 'RESET':
return wizardInitialState
default:
return state
}
}
function WizardForm() {
const [state, dispatch] = useReducer(wizardReducer, wizardInitialState)
function validateStep() {
const errors = {}
const data = state.formData[getStepKey(state.currentStep)]
if (state.currentStep === 1) {
if (!data.username) errors.username = 'Required'
if (!data.email?.includes('@')) errors.email = 'Invalid email address'
if (!data.password || data.password.length < 6) errors.password = 'Password must be at least 6 chars'
}
if (state.currentStep === 2) {
if (!data.name) errors.name = 'Required'
}
return errors
}
function handleNext() {
const errors = validateStep()
if (Object.keys(errors).length > 0) {
dispatch({ type: 'SET_ERRORS', errors })
return
}
dispatch({ type: 'NEXT_STEP' })
}
function handleSubmit() {
dispatch({ type: 'SUBMIT_START' })
// Simulation API Request
setTimeout(() => {
dispatch({ type: 'SUBMIT_SUCCESS' })
}, 1500)
}
if (state.completed) {
return (
<div style={{ textAlign: 'center', padding: '40px' }}>
<h2>🎉 Registration Successful!</h2>
<pre style={{ textAlign: 'left', background: '#f5f5f5', padding: '16px', borderRadius: '4px' }}>
{JSON.stringify(state.formData, null, 2)}
</pre>
<button onClick={() => dispatch({ type: 'RESET' })}>Re-register</button>
</div>
)
}
const stepKey = getStepKey(state.currentStep)
return (
<div style={{ maxWidth: '500px', margin: '0 auto' }}>
<h2>📝 Register(Steps {state.currentStep}/3)</h2>
{/* Step Indicator */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '24px' }}>
{['Account Information', 'Personal Information', 'Preferences'].map((label, i) => (
<div key={i} style={{
flex: 1, padding: '8px', textAlign: 'center',
backgroundColor: state.currentStep >= i + 1 ? '#1890ff' : '#f0f0f0',
color: state.currentStep >= i + 1 ? 'white' : '#999',
borderRadius: '4px', fontSize: '13px'
}}>
{label}
</div>
))}
</div>
{/* Form Content */}
{state.currentStep === 1 && <AccountForm formData={state.formData.account} errors={state.errors} dispatch={dispatch} />}
{state.currentStep === 2 && <ProfileForm formData={state.formData.profile} errors={state.errors} dispatch={dispatch} />}
{state.currentStep === 3 && <PreferenceForm formData={state.formData.preferences} errors={state.errors} dispatch={dispatch} />}
{/* Error Message */}
{state.errors.submit && <p style={{ color: '#ff4d4f' }}>{state.errors.submit}</p>}
{/* Control Buttons */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '24px' }}>
<button onClick={() => dispatch({ type: 'PREV_STEP' })} disabled={state.currentStep === 1}
style={btnStyle(state.currentStep === 1 ? '#d9d9d9' : '#666')}>
Previous Step
</button>
{state.currentStep < 3 ? (
<button onClick={handleNext} style={btnStyle('#1890ff')}>
Next Step
</button>
) : (
<button onClick={handleSubmit} disabled={state.isSubmitting} style={btnStyle(state.isSubmitting ? '#d9d9d9' : '#52c41a')}>
{state.isSubmitting ? 'Submitting......' : 'Submit Registration'}
</button>
)}
</div>
</div>
)
}
function getStepKey(step) {
return ['account', 'profile', 'preferences'][step - 1]
}
function AccountForm({ formData, errors, dispatch }) {
return (
<div>
<input name="username" value={formData.username} onChange={e => dispatch({ type: 'UPDATE_FIELD', section: 'account', field: 'username', value: e.target.value })}
placeholder="Username" style={inputStyle(errors.username)} />
{errors.username && <p style={{ color: '#ff4d4f', fontSize: '12px' }}>{errors.username}</p>}
<input name="email" value={formData.email} onChange={e => dispatch({ type: 'UPDATE_FIELD', section: 'account', field: 'email', value: e.target.value })}
placeholder="Email" style={inputStyle(errors.email)} />
{errors.email && <p style={{ color: '#ff4d4f', fontSize: '12px' }}>{errors.email}</p>}
<input name="password" type="password" value={formData.password} onChange={e => dispatch({ type: 'UPDATE_FIELD', section: 'account', field: 'password', value: e.target.value })}
placeholder="Password" style={inputStyle(errors.password)} />
{errors.password && <p style={{ color: '#ff4d4f', fontSize: '12px' }}>{errors.password}</p>}
</div>
)
}
function ProfileForm({ formData, errors, dispatch }) {
return (
<div>
<input name="name" value={formData.name} onChange={e => dispatch({ type: 'UPDATE_FIELD', section: 'profile', field: 'name', value: e.target.value })}
placeholder="Name" style={inputStyle(errors.name)} />
{errors.name && <p style={{ color: '#ff4d4f', fontSize: '12px' }}>{errors.name}</p>}
<textarea name="bio" value={formData.bio} onChange={e => dispatch({ type: 'UPDATE_FIELD', section: 'profile', field: 'bio', value: e.target.value })}
placeholder="Personal Profile" rows={3} style={{ width: '100%', padding: '8px', border: '1px solid #d9d9d9', borderRadius: '4px', marginBottom: '8px' }} />
</div>
)
}
function PreferenceForm({ formData, errors, dispatch }) {
return (
<div>
<p>Interface Language:</p>
{['zh', 'en', 'ja', 'pt', 'ar'].map(lang => (
<label key={lang} style={{ marginRight: '12px' }}>
<input type="radio" checked={formData.language === lang}
onChange={() => dispatch({ type: 'UPDATE_FIELD', section: 'preferences', field: 'language', value: lang })} />
{lang.toUpperCase()}
</label>
))}
<p style={{ marginTop: '16px' }}>Topic:</p>
<select value={formData.theme} onChange={e => dispatch({ type: 'UPDATE_FIELD', section: 'preferences', field: 'theme', value: e.target.value })}
style={{ width: '100%', padding: '8px', border: '1px solid #d9d9d9', borderRadius: '4px' }}>
<option value="light">Light-colored</option>
<option value="dark">Dark</option>
</select>
<label style={{ display: 'block', marginTop: '12px' }}>
<input type="checkbox" checked={formData.newsletter}
onChange={e => dispatch({ type: 'UPDATE_FIELD', section: 'preferences', field: 'newsletter', value: e.target.checked })} />
Subscribe to the Weekly Tech Report
</label>
</div>
)
}
function inputStyle(hasError) {
return {
width: '100%', padding: '8px', marginBottom: '4px',
border: `1px solid ${hasError ? '#ff4d4f' : '#d9d9d9'}`,
borderRadius: '4px'
}
}
function btnStyle(bgColor) {
return {
padding: '10px 24px', backgroundColor: bgColor,
color: 'white', border: 'none', borderRadius: '4px',
cursor: bgColor === '#d9d9d9' ? 'not-allowed' : 'pointer'
}
}
期待される出力:3段階の登録フォーム(アカウント → プロフィール → 設定)。各段階でバリデーションが行われ、前後のステップへ移動できる機能を備え、最終的な送信後に送信結果が表示されるもの。
(1) ▶ サンプル:非同期データ読み込みと useReducer
JSX
// ============================================
// Example:Asynchronous Data Loading——useReducer Three States of Management Requests
// Features:Display useReducer How to Handle It Gracefully loading / success / error Three States
// ============================================
import { useReducer, useEffect } from 'react'
// 1. Defining States and action Type
const initialState = {
data: null,
loading: true,
error: null
}
function dataReducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null }
case 'FETCH_SUCCESS':
return { data: action.payload, loading: false, error: null }
case 'FETCH_ERROR':
return { data: null, loading: false, error: action.error }
case 'RESET':
return initialState
default:
return state
}
}
// 2. Used in components
function UserDataFetcher({ userId }) {
const [state, dispatch] = useReducer(dataReducer, initialState)
useEffect(() => {
if (!userId) return
dispatch({ type: 'FETCH_START' })
// Simulation API Request
const timer = setTimeout(() => {
if (userId <= 0) {
dispatch({ type: 'FETCH_ERROR', error: 'Invalid User ID' })
} else {
// Simulate a successful response
const mockUser = {
id: userId,
name: `User ${userId}`,
email: `user${userId}@example.com`,
role: userId === 1 ? 'Administrator' : 'Regular User',
joinDate: '2026-01-15'
}
dispatch({ type: 'FETCH_SUCCESS', payload: mockUser })
}
}, 1000)
return () => {
clearTimeout(timer)
dispatch({ type: 'RESET' })
}
}, [userId])
// 3. Rendering Varies Based on State UI
if (state.loading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<div style={{ fontSize: '32px', marginBottom: '8px' }}>⏳</div>
<p style={{ color: '#666' }}>Loading user data...</p>
</div>
)
}
if (state.error) {
return (
<div style={{ textAlign: 'center', padding: '20px', color: '#ff4d4f' }}>
<div style={{ fontSize: '32px', marginBottom: '8px' }}>❌</div>
<p>Failed to load:{state.error}</p>
</div>
)
}
if (!state.data) {
return <p style={{ color: '#999', textAlign: 'center' }}>Please select a user</p>
}
const { name, email, role, joinDate } = state.data
return (
<div style={{
border: '1px solid #e8e8e8',
borderRadius: '8px',
padding: '20px',
maxWidth: '400px',
margin: '0 auto'
}}>
<h3>👤 User Information</h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
{[
['Name', name],
['Email', email],
['Characters', role],
['Date of Registration', joinDate]
].map(([label, value]) => (
<tr key={label}>
<td style={{ padding: '8px', color: '#666', fontWeight: 'bold', width: '80px' }}>{label}</td>
<td style={{ padding: '8px' }}>{value}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// Usage
function App() {
const [userId, setUserId] = useState(1)
return (
<div style={{ maxWidth: '500px', margin: '0 auto' }}>
<h2>📡 Example of Asynchronous Data Loading</h2>
<div style={{ marginBottom: '12px', display: 'flex', gap: '8px' }}>
{[1, 2, 3].map(id => (
<button key={id} onClick={() => setUserId(id)}
style={{
padding: '6px 16px',
backgroundColor: userId === id ? '#1890ff' : '#f0f0f0',
color: userId === id ? 'white' : '#333',
border: 'none', borderRadius: '4px', cursor: 'pointer'
}}>
User {id}
</button>
))}
<button onClick={() => setUserId(-1)}
style={{ padding: '6px 16px', backgroundColor: '#ff4d4f', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
Triggered an error
</button>
</div>
<UserDataFetcher userId={userId} />
</div>
)
}
(2) ▶ サンプル:useReducer を使用した「元に戻す/やり直し」機能の実装
JSX
function undoReducer(state, action) {
switch (action.type) {
case 'SET':
return { past: [...state.past, state.present], present: action.payload, future: [] }
case 'UNDO':
if (state.past.length === 0) return state
const previous = state.past[state.past.length - 1]
return { past: state.past.slice(0, -1), present: previous, future: [state.present, ...state.future] }
case 'REDO':
if (state.future.length === 0) return state
const next = state.future[0]
return { past: [...state.past, state.present], present: next, future: state.future.slice(1) }
default:
return state
}
}
function UndoApp() {
const [{ past, present, future }, dispatch] = useReducer(undoReducer, {
past: [], present: '', future: [],
})
const [input, setInput] = useState('')
return (
<div style={{ maxWidth: 400, margin: '0 auto' }}>
<h3>Undo/Redo Editor</h3>
<input value={input} onChange={e => setInput(e.target.value)} placeholder="Type something..."
style={{ width: '100%', padding: 8, borderRadius: 4, marginBottom: 8 }} />
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<button onClick={() => { dispatch({ type: 'SET', payload: input }); setInput('') }}
style={{ padding: '6px 16px', cursor: 'pointer' }}>Save</button>
<button onClick={() => dispatch({ type: 'UNDO' })} disabled={past.length === 0}
style={{ padding: '6px 16px', cursor: past.length ? 'pointer' : 'not-allowed' }}>Undo</button>
<button onClick={() => dispatch({ type: 'REDO' })} disabled={future.length === 0}
style={{ padding: '6px 16px', cursor: future.length ? 'pointer' : 'not-allowed' }}>Redo</button>
</div>
<p>Current: <strong>{present || '(empty)'}</strong></p>
<p style={{ fontSize: 12, color: '#999' }}>Past: [{past.join(', ')}] | Future: [{future.join(', ')}]</p>
</div>
)
}
(3) ▶ サンプル:useReducer を使用したフォーム状態の管理
JSX
const initialForm = { username: '', email: '', password: '', role: 'user', agree: false, errors: {} }
function formReducer(state, action) {
switch (action.type) {
case 'UPDATE_FIELD':
return { ...state, [action.field]: action.value, errors: { ...state.errors, [action.field]: undefined } }
case 'SET_ERRORS':
return { ...state, errors: action.errors }
case 'RESET':
return initialForm
default:
return state
}
}
function FormWithReducer() {
const [form, dispatch] = useReducer(formReducer, initialForm)
function validate() {
const errors = {}
if (!form.username.trim()) errors.username = 'Required'
if (!form.email.includes('@')) errors.email = 'Invalid email'
if (form.password.length < 6) errors.password = 'At least 6 chars'
if (!form.agree) errors.agree = 'Must agree'
return errors
}
function handleSubmit(e) {
e.preventDefault()
const errors = validate()
if (Object.keys(errors).length > 0) { dispatch({ type: 'SET_ERRORS', errors }); return }
alert(`Submitted: ${form.username}`)
}
function fieldStyle(key) {
return { width: '100%', padding: 8, borderRadius: 4, border: form.errors[key] ? '1px solid #ff4d4f' : '1px solid #d9d9d9', marginBottom: 4 }
}
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '0 auto' }}>
<h3>Registration</h3>
<input value={form.username} onChange={e => dispatch({ type: 'UPDATE_FIELD', field: 'username', value: e.target.value })} placeholder="Username" style={fieldStyle('username')} />
{form.errors.username && <p style={{ color: '#ff4d4f', fontSize: 12 }}>{form.errors.username}</p>}
<input value={form.email} onChange={e => dispatch({ type: 'UPDATE_FIELD', field: 'email', value: e.target.value })} placeholder="Email" style={fieldStyle('email')} />
{form.errors.email && <p style={{ color: '#ff4d4f', fontSize: 12 }}>{form.errors.email}</p>}
<input type="password" value={form.password} onChange={e => dispatch({ type: 'UPDATE_FIELD', field: 'password', value: e.target.value })} placeholder="Password" style={fieldStyle('password')} />
{form.errors.password && <p style={{ color: '#ff4d4f', fontSize: 12 }}>{form.errors.password}</p>}
<label style={{ display: 'block', margin: '8px 0' }}>
<input type="checkbox" checked={form.agree} onChange={e => dispatch({ type: 'UPDATE_FIELD', field: 'agree', value: e.target.checked })} /> I agree
</label>
<button type="submit" style={{ padding: '8px 24px', cursor: 'pointer' }}>Submit</button>
<button type="button" onClick={() => dispatch({ type: 'RESET' })} style={{ padding: '8px 16px', marginLeft: 8, cursor: 'pointer' }}>Reset</button>
</form>
)
}
(4) ▶ サンプル:グローバルなショッピングカートにおける useReducer と Context の活用
JSX
const CartContext = createContext(null)
const cartInitial = { items: [], coupon: null }
function cartReducer(state, action) {
switch (action.type) {
case 'ADD': {
const existing = state.items.find(i => i.id === action.product.id)
return {
...state,
items: existing
? state.items.map(i => i.id === action.product.id ? { ...i, qty: i.qty + 1 } : i)
: [...state.items, { ...action.product, qty: 1 }],
}
}
case 'REMOVE':
return { ...state, items: state.items.filter(i => i.id !== action.id) }
case 'SET_QTY':
return { ...state, items: state.items.map(i => i.id === action.id ? { ...i, qty: action.qty } : i) }
case 'CLEAR':
return cartInitial
default:
return state
}
}
function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, cartInitial)
const total = useMemo(() => state.items.reduce((sum, i) => sum + i.price * i.qty, 0), [state.items])
const value = useMemo(() => ({ ...state, total, dispatch }), [state, total])
return <CartContext.Provider value={value}>{children}</CartContext.Provider>
}
function useCart() { return useContext(CartContext) }
function ProductList() {
const { dispatch } = useCart()
const products = [{ id: 1, name: 'Keyboard', price: 79 }, { id: 2, name: 'Mouse', price: 49 }, { id: 3, name: 'Monitor', price: 399 }]
return (
<div>
<h3>Products</h3>
{products.map(p => (
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', padding: 8, borderBottom: '1px solid #f0f0f0' }}>
<span>{p.name} - ${p.price}</span>
<button onClick={() => dispatch({ type: 'ADD', product: p })} style={{ cursor: 'pointer' }}>Add</button>
</div>
))}
</div>
)
}
function CartSummary() {
const { items, total, dispatch } = useCart()
return (
<div>
<h3>Cart ({items.length} items)</h3>
{items.map(i => (
<div key={i.id} style={{ display: 'flex', justifyContent: 'space-between', padding: 4 }}>
<span>{i.name} x{i.qty}</span>
<span>${i.price * i.qty}</span>
</div>
))}
<hr />
<strong>Total: ${total}</strong>
<button onClick={() => dispatch({ type: 'CLEAR' })} style={{ marginLeft: 8, cursor: 'pointer' }}>Clear</button>
</div>
)
}
❓ よくある質問
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 で「やり直し」が実行できるようにしてください。



