Open this lesson in your favourite AI. It'll walk you through the why, explain the demo, and quiz you on the try-it list.
useReducer separates state from update logic. Where useState works well for independent values, useReducer shines when the next state depends on multiple pieces of current state, when several actions share similar logic, or when you need to co-locate all valid state transitions in one place. The dispatch function is referentially stable across renders — unlike setState callbacks, you can pass dispatch down through a deep component tree via context without wrapping it in useCallback, because React guarantees it never changes.
useReducer takes a reducer function and initial state. State updates are expressed as dispatched actions. The dispatch function identity is stable — safe to pass down without memoization.
import { useReducer } from 'react';
type Status = 'idle' | 'loading' | 'success' | 'error';
interface FetchState {
status: Status;
data: string | null;
error: string | null;
}
type FetchAction =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: string }
| { type: 'FETCH_ERROR'; payload: string }
| { type: 'RESET' };
function fetchReducer(state: FetchState, action: FetchAction): FetchState {
switch (action.type) {
case 'FETCH_START':
return { status: 'loading', data: null, error: null };
case 'FETCH_SUCCESS':
return { status: 'success', data: action.payload, error: null };
case 'FETCH_ERROR':
return { status: 'error', data: null, error: action.payload };
case 'RESET':
return { status: 'idle', data: null, error: null };
default:
return state;
}
}
const initialState: FetchState = { status: 'idle', data: null, error: null };
export function FetchButton() {
const [state, dispatch] = useReducer(fetchReducer, initialState);
async function handleFetch() {
dispatch({ type: 'FETCH_START' });
try {
await new Promise(r => setTimeout(r, 800));
dispatch({ type: 'FETCH_SUCCESS', payload: 'Hello from server' });
} catch {
dispatch({ type: 'FETCH_ERROR', payload: 'Network error' });
}
}
return (
<div>
<button onClick={handleFetch} disabled={state.status === 'loading'}>
{state.status === 'loading' ? 'Loading…' : 'Fetch'}
</button>
{state.data && <p>{state.data}</p>}
{state.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state.status !== 'idle' && (
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
)}
</div>
);
}fetchReducer outside the component and write unit tests for it in isolation. Pass each action type and assert the next state. This is the key ergonomic advantage over useState — pure functions are trivially testable.status, data, error). Count how many places you set multiple pieces of state together. Notice how useReducer centralizes those transitions into one function.dispatch down to a deeply nested child via Context. Confirm that dispatch is referentially stable (the child doesn't re-render when the parent's other state changes). Compare to passing a setStatus callback and add useCallback — dispatch requires no wrapping.Use these three in order. Each builds on the one before.
In one paragraph, explain when useReducer is a better choice than useState. What makes it easier to reason about state transitions?
Walk me through what React does when dispatch is called. How does the reducer run, how does React know to schedule a re-render, and what guarantees does React give about dispatch's identity across renders?
useReducer combined with Context is often described as 'poor man's Redux'. Describe the tradeoffs between this pattern and a dedicated state library like Zustand — specifically around performance (unnecessary re-renders), devtools support, and middleware.