useReducer Deep Dive: Actions, Dispatch, and the Reducer Pattern
useReducer is not just useState with extra steps. It enforces an explicit event-action model that makes state transitions auditable, testable, and easy to reason about as complexity grows.
useState works well for simple, independent values. When you have multiple pieces of related state — or multiple operations that modify the same state in different ways — the logic starts to scatter across several setter calls and the component becomes hard to follow.
useReducer is the alternative. It centralizes all state transitions into a single function, following the same pattern Redux popularized. That is where the name comes from.
const [state, dispatch] = useReducer(reducer, { count: 0 });Instead of a value and a setter, you get the current state object and a dispatch function. The initial state is an object. The reducer function you pass in handles every possible transition.
Writing the Reducer Function
The reducer receives two arguments: the current state and an action. It returns the next state.
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'double':
return { count: state.count * 2 };
default:
console.log('Unknown action type:', action.type);
return state;
}
}A few things to note here. The switch handles every named action type and returns a new state object for each. The default case is not optional — dispatching a type that has no handler would otherwise fail silently. Return the unchanged state and log the issue so it is visible during development.
Dispatching Actions
To trigger a state change, call dispatch with an action object that has a type property:
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'double' })}>×2</button>None of the state logic lives in the component. The component describes what happened — a button was clicked, an action was taken. The reducer decides what the state becomes as a result. That separation is the entire point.
Passing Data with Actions
Actions can carry additional data as a payload:
dispatch({ type: 'setCount', payload: 42 });
// in the reducer:
case 'setCount':
return { count: action.payload };This is the standard convention. The type identifies the action. The payload carries whatever data the reducer needs to compute the next state.
When useReducer Earns Its Place
For a counter with one value, useReducer is clearly more code than useState. The value shows when state grows.
Consider a user object with multiple fields:
const [state, dispatch] = useReducer(reducer, {
name: '',
email: '',
address: '',
isVerified: false,
});Every operation that touches this user — updating the email, marking it verified, resetting the form — lives inside one function. You dispatch a named action and the reducer handles it. No scattered setter calls. No risk of accidentally mixing updates to unrelated fields.
The reducer is also trivially testable. It is a pure function: same inputs, same outputs, no side effects. You can test every state transition in isolation without mounting a component.
expect(reducer({ count: 5 }, { type: 'increment' })).toEqual({ count: 6 });
expect(reducer({ count: 0 }, { type: 'decrement' })).toEqual({ count: -1 });The larger and more interconnected your state, the more useReducer pays off over useState.
Practice what you just read.
Keep reading