React: Complex State: useReducer
Last updated: 2026-08-26
1. What You'll Learn
- useReducer 's basic pattern(state + action → newState)
- Design Principles for Action Types
- Comparison with useState (When to Use Which)
- useReducer + Context combo (lightweight Redux)
2. A Story About Shopping Cart State Management
(1) Pain Point: Complex Interactions Between Multiple useState Instances
Alice is building a shopping cart component that needs to handle multiple interactions:
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) The useReducer Solution
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>
)
}
Benefits: All state changes are centralized in a single reducer function—predictable, testable, and easy to debug. Each action is a record of "what happened."
3. The Core Pattern of useReducer
| Concept | Description | Example |
|---|---|---|
reducer |
Pure function,(state, action) → newState | function cartReducer(state, action) |
action |
An object describing "what happened" | { type: 'ADD_ITEM', payload: product } |
dispatch |
Function to send an action | dispatch({ type: 'INCREMENT' }) |
initialState |
Initial State Object | { items: [], total: 0 } |
init Function |
Lazy Initialization (Optional 3rd Parameter) | 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) Basic Syntax
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) Action Design Principles
| Principle | Description | Bad Example | Good Example |
|---|---|---|---|
| Use the past tense for type names | Describes "what has already happened" | ADD |
ITEM_ADDED |
| Carry the Minimum Amount of Data | Carry only the necessary data; calculate the rest in the reducer | dispatch({ type: 'SET_TOTAL', total: 42 }) |
dispatch({ type: 'ITEM_ADDED', item }), where total is calculated in the reducer |
| Do not handle side effects | Reducers must be pure functions (do not call APIs or read/write localStorage) | case 'LOGIN': fetch(...) |
Call APIs in useEffect or event handlers, then dispatch after completion |
4. useReducer vs useState
| Dimension | useState | useReducer |
|---|---|---|
| Number of states | 3 or fewer | More than 3, or states are interrelated |
| Update Logic | Simple (toggle, +1) | Complex (multiple sub-values that influence each other) |
| Readability | Good (easy to understand at a glance) | Good (centralized management) |
| Testability | Components that require rendering must be tested | Reducers are pure functions and can be tested directly |
| Suitable Scale | Small components, standalone states | Medium to large components, complex forms, shopping carts |
▶ Example: Comparison of Different Implementations of the Same Functionality
Output:
TEXT
📖 Display only
Side-by-side comparison: HTML syntax vs JSX syntax showing equivalent markup
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>
)
}
Output:
TEXT
📖 Display only
Count: 0 | Last Action: none | Operations: 0. +1→INCREMENT, -1→DECREMENT, Reset→initial, Set 100. History tracks all actions.
5. useReducer + Context: Lightweight Global State
When multiple components need to share the same reducer state, use Context to pass the 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. Complete Example: Multi-Step Form (Wizard)
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'
}
}
Expected Output: A 3-step registration form (Account → Profile → Preferences), with validation at each step, the ability to move forward and backward, and final submission followed by the display of the submission result.
▶ Example 2: Asynchronous Data Loading and useReducer
Output:
TEXT
📖 Display only
Displays: "Reset". State: count (setter: setCount). Buttons: setCount(c => c + 1)}>+1, setCount(0)}>Reset, dispatch({ type: 'INCREMENT' })}>+1, dispatch({ type: 'DECREMENT' })}>-1. useReducer manages complex state
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>
)
}
Output:
TEXT
📖 Display only
Click User 1/2/3 → ⏳ Loading... → 👤 card (User 1, user1@example.com, Administrator, 2026-01-15). "Trigger error" → ❌ "Invalid User ID"
▶ Example 3: Implementing Undo/Redo Functionality with useReducer
Output:
TEXT
📖 Display only
Undo/Redo editor: type text → Save. Undo reverts, Redo restores. Shows past/future state stacks
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>
)
}
Output:
TEXT
📖 Display only
Type → Save → "Current: hello". Undo → reverts to previous. Redo → restores. Shows: Past: [v1, v2] | Future: [v4]
▶ Example 4: Using useReducer to Manage Form State
Output:
TEXT
📖 Display only
Registration form via useReducer: username, email, password, role, agree checkbox. Submit validates fields. Reset clears all
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>
)
}
Output:
TEXT
📖 Display only
Registration form (Username, Email, Password, Agree checkbox) via useReducer. Invalid submit → red errors. Reset → clears all fields.
▶ Example 5: useReducer + Context for a Global Shopping Cart
Output:
TEXT
📖 Display only
Shopping cart context: product list with "Add" buttons. Cart shows items + total. "Clear" empties cart
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>
)
}
Output:
TEXT
📖 Display only
Products (Keyboard $79, Mouse $49, Monitor $399) with "Add". Cart: items × qty + Total. "Clear" empties cart. Global state via Context.
❓ FAQ
Q Why must a reducer be a pure function?
A A pure function means: the same input → the same output; it does not modify external variables; and it has no side effects (does not call APIs or read/write files). React relies on the predictability of reducers for performance optimization (skipping unnecessary renders) and debugging (time travel). Calling APIs inside a reducer can lead to unpredictable state changes, which undermines the debugging experience.
Q When should you switch from
useState to useReducer?A When any of the following three situations occurs: ① A single action needs to update three or more
useState instances (e.g., clicking a button simultaneously updates isLoading, error, and data); ② The state logic is so complex that it’s scattered across multiple event handler functions, making it difficult to track; ③ The component has multiple state sub-values that depend on each other. When to switch: When the useState implementation causes your component to exceed 100 lines of code.Q Can I include side effects (such as fetch requests) in a reducer?
A Absolutely not. A reducer must be a pure function—it must always return the same output for the same input and must not have any side effects, such as API calls, DOM operations, or random numbers. Side effects should be placed in
useEffect or event handler functions. Writing side effects in a reducer leads to: unpredictable state, difficult debugging, and broken time travel (DevTools replay).📖 Summary
useReducer(reducer, initialState)Return[state, dispatch]- A reducer is a pure function:
(state, action) => newState - action contains
type(describing what happened) and an optionalpayload(data) useStateis suitable for simple, independent state, whileuseReduceris suitable for complex, interdependent stateuseReducer + ContextCan be used as an alternative to Redux in small projects
📝 Exercises
- Basic Exercise (Difficulty ⭐): Create a
Countercomponent and useuseReducerto implement four operations: +1, -1, +10, and reset. Display the current value and the operation history (the last 5 operations) on the page. - Advanced Exercise (Difficulty ⭐⭐): Create a
TodoAppcomponent and useuseReducerandContextto implement five functions: add, mark as completed, edit, delete, and clear completed items. - Challenge (Difficulty: ⭐⭐⭐): Create a
UndoRedoListcomponent and useuseReducerto implement "Undo/Redo" functionality. Each operation should save a history snapshot and support Ctrl+Z for Undo and Ctrl+Shift+Z for Redo.