Part 6.3 — useReducer for Complex State Transitions
As React apps grow, state stops being “a few booleans” and starts becoming a set of transitions.
That’s the moment useState begins to fight you—and useReducer starts to shine.
This post teaches you how to recognize that moment and how to use useReducer to model complex behavior cleanly.
1. When useState Is No Longer Enough
useState works well when:
state changes are simple
updates are isolated
logic fits in a setter
It breaks down when:
multiple state fields change together
updates depend on previous state
many events affect the same state
logic spreads across handlers
Example smell:
setLoading(true);
setError(null);
setItems([]);Scattered state updates hide intent.
2. Think in Events, Not Setters
Reducers shift your mindset from how to update state to what happened.
Instead of:
setLoading(true);You express:
dispatch({ type: 'LOAD_START' });This makes intent explicit and debuggable.
3. A Realistic Example: Data Fetching State
Let’s model a list that loads data.
State shape:
type State = {
status: 'idle' | 'loading' | 'success' | 'error';
items: Item[];
error?: string;
};Actions:
type Action =
| { type: 'LOAD_START' }
| { type: 'LOAD_SUCCESS'; payload: Item[] }
| { type: 'LOAD_ERROR'; error: string };This alone clarifies behavior.
4. Writing the Reducer
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'LOAD_START':
return { status: 'loading', items: [] };
case 'LOAD_SUCCESS':
return { status: 'success', items: action.payload };
case 'LOAD_ERROR':
return { status: 'error', items: [], error: action.error };
default:
return state;
}
}Notice:
no side effects
no async logic
no mutations
Reducers describe transitions, not processes.
5. Using the Reducer in a Component
const [state, dispatch] = React.useReducer(reducer, {
status: 'idle',
items: [],
});
React.useEffect(() => {
dispatch({ type: 'LOAD_START' });
fetch('/api/items')
.then(res => res.json())
.then(data =>
dispatch({ type: 'LOAD_SUCCESS', payload: data }),
)
.catch(() =>
dispatch({ type: 'LOAD_ERROR', error: 'Failed to load' }),
);
}, []);Now all state changes flow through one place.
6. Rendering from State, Not Flags
if (state.status === 'loading') return <Spinner />;
if (state.status === 'error') return <Error message={state.error} />;
return <ItemList items={state.items} />;This avoids:
conflicting booleans
impossible states
defensive UI logic
The reducer enforces valid combinations.
7. Keep Reducers Pure and Boring
Reducers must:
be synchronous
return new state
have no side effects
never call APIs
Side effects belong in:
event handlers
useEffectasync functions
This separation is what makes reducers predictable.
8. Split Reducers by Domain, Not Component Size
If a reducer grows too large, don’t split by file size—split by responsibility.
Example:
authReduceritemsReducerformReducer
Each reducer should model one domain.
9. Avoid Over-Engineering Reducers
You don’t need reducers for:
toggling a boolean
controlled inputs
local UI state
Use reducers when:
state transitions matter
multiple events affect the same data
clarity beats brevity
Reducers add structure—don’t add them prematurely.
10. Testing Reducers Is Trivial (and Powerful)
Reducers are pure functions.
it('handles LOAD_SUCCESS', () => {
const initial: State = { status: 'loading', items: [] };
const next = reducer(initial, {
type: 'LOAD_SUCCESS',
payload: [{ id: 1 }],
});
expect(next.status).toBe('success');
expect(next.items.length).toBe(1);
});This is cheap, fast, and reliable.
11. Reducers as Documentation
Reducers document:
what can happen
how the system responds
which states are valid
New teammates learn behavior by reading reducers—not tracing event handlers.
12. Common Reducer Smells
Watch for:
reducers doing async work
action types like
SET_FIELD_Xdeeply nested state updates
reducers growing without boundaries
These indicate missing architecture decisions.
13. Summary
You now know how to:
recognize when
useReduceris appropriatemodel state as transitions
design clear action types
keep reducers pure
simplify rendering logic
test state behavior easily
Related
Part 6.6 — Async State, Side Effects & Server State Boundaries
Most React state bugs come from mixing UI state with server state. This post teaches you how to draw a hard line between them—and why that line matters.
Part 6.5 — Introducing Redux Toolkit the Right Way
Redux isn’t the default anymore—but when your app needs it, Redux Toolkit is the cleanest, safest way to introduce global state at scale.
Part 6.4 — Combining Context + useReducer for App-Level State
Context provides access. Reducers provide structure. Together, they form a powerful, lightweight architecture for managing app-level state in React.
Comments