useReducer
useReducer is useState for complex state transitions. Dispatch typed actions; a reducer pure-function returns the next state. Better than useState when state has many interrelated fields or transitions form a state machine.
Reducer, dispatch, with TS, with Context
EXAMPLE
import { useReducer } from 'react';
// 1) Define state + actions + reducer
type State = { count: number; busy: boolean; error: string | null };
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' }
| { type: 'set'; value: number }
| { type: 'start' }
| { type: 'fail'; message: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment': return { ...state, count: state.count + 1 };
case 'decrement': return { ...state, count: state.count - 1 };
case 'reset': return { count: 0, busy: false, error: null };
case 'set': return { ...state, count: action.value };
case 'start': return { ...state, busy: true, error: null };
case 'fail': return { ...state, busy: false, error: action.message };
}
}
const initial: State = { count: 0, busy: false, error: null };
function Counter() {
const [state, dispatch] = useReducer(reducer, initial);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
<button onClick={() => dispatch({ type: 'set', value: 10 })}>=10</button>
<button onClick={() => dispatch({ type: 'reset' })}>reset</button>
</div>
);
}
// 2) Lazy init — runs initialiser once
function init(initialCount: number): State {
return { count: initialCount, busy: false, error: null };
}
function Counter2({ start = 0 }) {
const [state, dispatch] = useReducer(reducer, start, init);
// ...
}
// 3) Async work via dispatch — the modern Redux-Toolkit pattern
async function load(dispatch: React.Dispatch<Action>) {
dispatch({ type: 'start' });
try {
const value = await api.fetchCount();
dispatch({ type: 'set', value });
} catch (e) {
dispatch({ type: 'fail', message: String(e) });
}
}
useEffect(() => { load(dispatch); }, []);
// 4) Pair with Context — share state across deep trees without prop drilling
const StateCtx = createContext<State>(null!);
const DispatchCtx = createContext<React.Dispatch<Action>>(null!);
export function StoreProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, initial);
return (
<StateCtx.Provider value={state}>
<DispatchCtx.Provider value={dispatch}>{children}</DispatchCtx.Provider>
</StateCtx.Provider>
);
}
export const useStore = () => useContext(StateCtx);
export const useDispatch = () => useContext(DispatchCtx);
// 5) When to pick useReducer over useState
// • State has multiple related fields that change together
// • Updates depend on previous state + form a state machine
// • You want a clear list of valid transitions (auditable)
// • You're tempted to write a 200-line component with 8 useState calls
// 6) When useState is fine
// • One value with simple transitions
// • No complex update logic
// • Small, isolated component
// 7) Tips
// • Use discriminated-union action types — TS gives exhaustive checks
// • Reducers must be PURE — no fetch, no setTimeout, no Math.random
// • Avoid storing derived state — compute from state at render time (or via useMemo)
// • For server state, prefer TanStack Query / SWR over a custom reducer
Why it matters
useReducer pays off when your component starts looking like a state machine. Discriminated-union actions + an exhaustive switch turn transitions into something the compiler verifies for you.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
const [state, dispatch] = useReducer(reducer, initial);
dispatch({ type: 'increment' });
Try it Yourself »
Discussion
Loading…