Updated June 2026. Tested on React 19 and modern JavaScript. Part of the Techalyst React series.
useState is perfect for a few independent values. But when a component grows several pieces of state that change together, or transitions that depend on what came before, a pile of useState calls and scattered setters gets hard to follow. useReducer is the tidier answer: you describe what happened, and one function decides the next state. It is the same model Redux made popular, built right into React.
The shape of it
A reducer is a pure function that takes the current state and an action, and returns the next state. You dispatch actions, and React runs them through the reducer:
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'reset' })}>reset</button>
</>
);
}
The component does not change state directly. It dispatches an action, a plain object describing what happened, and the reducer holds all the logic for turning that into new state.
Why this is nicer for complex state
Two things make this pay off as state grows. First, all the update logic lives in one pure function instead of being spread across handlers, which makes it easy to read and easy to test, you just call reducer(state, action) and check the output, no React needed. Second, components become declarative about intent: a button says dispatch({ type: 'addItem', item }), and what that actually does is the reducer's problem. For forms, wizards, carts, anything with several related fields and transitions, this scales far better than a dozen setters.
Keep the reducer immutable
A reducer must return a new state object, never mutate the existing one. The same rule that governs setState applies here, and for the same reason:
// Right: new object, spread the rest
case 'setName':
return { ...state, name: action.name };
If you reach for state.name = action.name, you are mutating, and React may not see the change. This is covered in depth in immutable state updates.
useState or useReducer
The choice is about how the state behaves, not how big it is. Reach for useState when values are simple and independent, a toggle here, an input there. Reach for useReducer when several pieces of state update together, when the next state depends on the current one in non-trivial ways, or when the update logic is getting complicated enough that you want it in one testable place. If your setters keep needing the previous state and each other, that is the signal to switch.
Pairing with Context
A reducer plus Context gives you a small global store without a library. You put the state and the dispatch into a context near the top, and any component can read the state or dispatch actions, the same pattern a state library formalises. For a moderate app, this is often all the global state management you need.
Wrapping up
useReducer moves your state logic into one pure reducer(state, action) function and has components dispatch actions that describe what happened. It keeps complex, interrelated state readable and testable, as long as the reducer stays immutable and returns new objects. Use useState for simple independent values and switch to useReducer when updates travel together or depend on each other, and pair it with Context when you want that logic available across the tree.
All comments ()
No comments yet
Be the first to leave a comment on this post.