10 — useReducer & Complex State
useState vs useReducer: The Decision
usestate_vs_usereducer.js
// useState: independent values, simple updates, 1-3 state fields
// useReducer: interdependent state, complex transitions, many fields, state machines
// USE useState WHEN:
// - State is a single primitive or simple object
// - Updates are independent (setA doesn't affect B)
// - Few state fields (1-3)
// - Update logic is a one-liner
// USE useReducer WHEN:
// - Multiple state fields update together (interdependent)
// - Next state depends on complex conditions
// - You need a clear audit trail of state transitions (actions)
// - State has a finite set of well-defined "modes" or "phases"
// - Update logic is more than 2-3 lines
// - Testing the state logic separately matters (reducers are pure functions)
useReducer Anatomy
usereducer_anatomy.js
import { useReducer } from 'react'
// useReducer(reducer, initialState) → [state, dispatch]
// reducer: (state, action) → newState (PURE function, no side effects)
// action: { type: string, ...payload }
const initialState = { count: 0, step: 1 }
function counterReducer(state, action) {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + state.step }
case 'decrement':
return { ...state, count: state.count - state.step }
case 'setStep':
return { ...state, step: action.step }
case 'reset':
return initialState
default:
// CRITICAL: always return state in default — throwing is also acceptable.
// Returning state (not throwing) means unknown actions are silently ignored.
// Throwing catches bugs in development: assertUnreachable(action.type)
return state
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, initialState)
return (
<div>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>−</button>
<button onClick={() => dispatch({ type: 'setStep', step: 5 })}>Step 5</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
)
}
Production Pattern: Multi-Field Form
form_reducer.js
const initialFormState = {
values: { name: '', email: '', password: '' },
errors: {},
touched: { name: false, email: false, password: false },
isSubmitting: false,
}
function formReducer(state, action) {
switch (action.type) {
case 'field_change':
// Update a single field value, clear its error, keep touched as-is
return {
...state,
values: { ...state.values, [action.field]: action.value },
errors: { ...state.errors, [action.field]: undefined },
}
case 'field_blur':
// Mark field as touched and validate it
return {
...state,
touched: { ...state.touched, [action.field]: true },
errors: {
...state.errors,
[action.field]: validateField(action.field, state.values[action.field]),
},
}
case 'start_submit':
// Validate all fields, set submitting only if no errors
const allErrors = validateAll(state.values)
return {
...state,
touched: { name: true, email: true, password: true },
errors: allErrors,
isSubmitting: Object.keys(allErrors).length === 0,
}
case 'submit_success':
return { ...initialFormState } // reset everything
case 'submit_error':
return { ...state, isSubmitting: false, errors: { ...state.errors, submit: action.error } }
default:
return state
}
}
// Usage: const [formState, dispatch] = useReducer(formReducer, initialFormState)
// dispatch({ type: 'field_change', field: 'email', value: 'user@test.com' })
// dispatch({ type: 'field_blur', field: 'email' })
// dispatch({ type: 'start_submit' })
State Machine: Async Data Fetching
async_state_machine.js
// A finite state machine for async operations: idle → loading → success/error
const initialState = { status: 'idle', data: null, error: null }
function asyncReducer(state, action) {
switch (action.type) {
case 'fetch_start':
return { status: 'loading', data: null, error: null }
case 'fetch_success':
return { status: 'success', data: action.data, error: null }
case 'fetch_error':
return { status: 'error', data: null, error: action.error }
case 'reset':
return initialState
default:
return state
}
}
function useAsyncFetch(fetchFn, deps = []) {
const [state, dispatch] = useReducer(asyncReducer, initialState)
useEffect(() => {
let cancelled = false
dispatch({ type: 'fetch_start' })
fetchFn()
.then(data => { if (!cancelled) dispatch({ type: 'fetch_success', data }) })
.catch(error => { if (!cancelled) dispatch({ type: 'fetch_error', error: error.message }) })
return () => { cancelled = true }
}, deps)
return state
}
// The state machine makes IMPOSSIBLE states unreachable:
// You can't be in "loading" and "success" at the same time.
// With separate useStates (setStatus, setData, setError), you CAN accidentally
// set inconsistent combinations: setStatus('loading') + setData(oldData).
Lazy Initialization with useReducer
lazy_init_reducer.js
// useReducer supports lazy initialization (same as useState):
// useReducer(reducer, initialArg, initFunction)
// initFunction(initialArg) runs only on mount.
function init(initialCount) {
// Read from localStorage or compute on mount only
const saved = localStorage.getItem('count')
return { count: saved ? parseInt(saved, 10) : initialCount, step: 1 }
}
function Counter({ initialCount }) {
const [state, dispatch] = useReducer(reducer, initialCount, init)
// init(initialCount) runs once on mount → no localStorage read on re-renders.
return <div>{state.count}</div>
}
💡 Tips & Tricks
tips.js
// [Idiom] Extract the reducer and action creators into a separate file for
// testability. Reducers are PURE functions — you can unit test them in isolation
// without rendering any React components:
// expect(reducer({ count: 0 }, { type: 'increment' })).toEqual({ count: 1 })
// [Idiom] Use a discriminated union for actions (TypeScript) to get autocomplete
// and exhaustive switch checking:
// type Action = { type: 'increment' } | { type: 'setStep', step: number }
// [Debug] If state updates seem wrong, log every action:
// function loggingReducer(state, action) { console.log(action); return realReducer(state, action) }
// [Idiom] For truly complex state, consider XState (finite state machine library)
// — it formalizes states, transitions, and guards. useReducer is a mini-FSM;
// XState is the full version when transitions have conditions and side effects.
// [Performance] dispatch is GUARANTEED stable (same as useState setters) — no
// need to useCallback it. Safe to pass dispatch directly to memoized children
// or include in dependency arrays without causing re-runs.
⚠️ Edge Cases & Gotchas
edge_cases.js
// [Gotcha] Reducers MUST be pure — no side effects, no async, no mutations.
// Don't fetch data, set timeouts, or dispatch from inside a reducer. Side effects
// belong in useEffect (triggered by state changes), not in the reducer itself.
// [Gotcha] Always return a NEW state object — never mutate the existing one.
// return state with a mutation → React sees the same reference → no re-render.
// return { ...state, count: state.count + 1 } → new object → re-render.
// [Gotcha] The default case in a switch should return state (not throw) for
// resilience, OR throw/assert for strictness. Silently returning state on
// unknown actions can mask typos in action type strings.
// [Gotcha] dispatch is stable but the state value changes. If you pass dispatch
// to a memoized child, it won't cause re-renders. But if you pass state, the
// child re-renders whenever state changes — even parts of state it doesn't use.
// Consider splitting state or using selectors (useSyncExternalStore).
🧠 Spot the Bug
A reducer seems to "lose" other fields when updating one:
spot_the_bug.js
function settingsReducer(state, action) {
switch (action.type) {
case 'setTheme':
return { theme: action.theme } // ← replaces the entire state object
case 'setFontSize':
return { fontSize: action.fontSize } // ← replaces again
default:
return state
}
}
Answer
Each case returns a new object with only one field — return { theme: action.theme } replaces the entire state, losing fontSize (and any other fields). The next setFontSize then loses theme.
Fix: spread the previous state and override only the changed field:
case 'setTheme':
return { ...state, theme: action.theme }
case 'setFontSize':
return { ...state, fontSize: action.fontSize }
The spread preserves all other fields while updating only the target. This is the same pattern as useState with object state (Chapter 4) — always create a new object with the previous fields merged in.
Key Takeaways
key_takeaways.js
// 1. useReducer over useState when: state has interdependent fields, complex
// transitions, many fields, or a finite set of "modes." Reducers are pure
// functions: (state, action) → newState, no side effects.
// 2. Reducers are testable in isolation — no React rendering needed. Extract to
// a separate file for unit testing state transitions.
// 3. Always spread previous state in the reducer: { ...state, changedField: value }.
// Returning a partial object replaces the entire state — other fields are lost.
// 4. State machines (idle → loading → success/error) make impossible states
// unreachable. With separate useStates, you can accidentally set inconsistent
// combinations (loading=true + data=stale). A reducer prevents this by design.
// 5. dispatch is guaranteed stable (like useState setters) — safe in dependency
// arrays and memoized children. Lazy initialization: useReducer(reducer, arg, initFn).