In one minute
Both hooks hold state; useState is in fact a useReducer with a built-in reducer. The choice is about shape. A value that changes on its own (a theme, an open flag, a search string) → useState. Several values that change together, or a next state that depends on other state → useReducer: all the transition logic goes into one pure function (state, action) => newState, React always calls it with the latest state, and the component just sends actions with dispatch. Start with useState; switch when you notice updates that have to read or keep several values in step.
You’ll be able to: pick the right hook, explain the stale-closure bug that tips the balance, and test state logic without rendering.
1const [past, setPast] = useState([])2const [present, setPresent] = useState('Ceramic Mug')3const set = useCallback(next => {4 setPast([...past, present]) // values from render 15 setPresent(next)6}, [past, present])78useEffect(() => set('Desk Lamp'), [])9useEffect(() => set('Trail Backpack'), [])- effects
useEffect(() => set('Desk Lamp'), [])The first effect callsset. It was created in render 1, so it readspast = []andpresent = "Ceramic Mug". - effects
setPast([...past, present]) // values from render 1It queuespast: ["Ceramic Mug"]andpresent: "Desk Lamp". - effects
useEffect(() => set('Trail Backpack'), [])The second effect calls the same function, with the same old snapshot. It also queuespast: ["Ceramic Mug"], overwriting the first. - render phaseRecorded final state: Desk Lamp is missing from the history.
1function historyReducer(state, action) {2 const { past, present } = state3 if (action.type === 'set') return {4 past: [...past, present],5 present: action.next, future: [],6 }7}89const [state, dispatch] = useReducer(historyReducer, initial)10const set = useCallback(next => dispatch({ type: 'set', next }), [])1112useEffect(() => set('Desk Lamp'), [])13useEffect(() => set('Trail Backpack'), [])- effects
useEffect(() => set('Desk Lamp'), [])setonly dispatches an action. It reads no state, so there’s nothing to go stale. - effects
useEffect(() => set('Trail Backpack'), [])A second action is queued. - render phase
past: [...past, present],React runs the reducer for each action with the current state; the second call sees the result of the first.
The example: “Recently viewed” with undo
The product page remembers what you looked at: past, present and future. Two things set it right after mount (a product opened from a link, another restored from a tab):
function RecentlyViewed({ useUndo }: { useUndo: typeof useUndoReducer }) {
const [state, set] = useUndo('Ceramic Mug')
useEffect(() => set('Desk Lamp'), []) // e.g. opened from a link
useEffect(() => set('Trail Backpack'), []) // e.g. restored from a tab
return <pre id="state">{JSON.stringify(state)}</pre>
}
Three useStates
function useUndoStates(initial: string) {
const [past, setPast] = useState<string[]>([])
const [present, setPresent] = useState(initial)
const [future, setFuture] = useState<string[]>([])
const set = useCallback(
(next: string) => {
if (next === present) return
setPast([...past, present]) // past and present come from this render
setPresent(next)
setFuture([])
},
[past, present],
)
return [{ past, present, future }, set] as const
}
final state: {"past":["Ceramic Mug"],"present":"Trail Backpack","future":[]}
“Desk Lamp” is missing from past. Both effects called the same set, created during the first render, so both read past = [] and present = "Ceramic Mug"; the second update overwrote the first. Adding set to the effects’ dependencies makes it worse: set changes whenever past/present change, so the effects would run again and again.
useReducer
type Action = { type: 'set'; next: string } | { type: 'undo' } | { type: 'redo' }
function historyReducer(state: History, action: Action): History {
const { past, present, future } = state
switch (action.type) {
case 'set':
if (action.next === present) return state
return { past: [...past, present], present: action.next, future: [] }
case 'undo':
if (!past.length) return state
return { past: past.slice(0, -1), present: past[past.length - 1], future: [present, ...future] }
case 'redo':
if (!future.length) return state
return { past: [...past, present], present: future[0], future: future.slice(1) }
}
}
function useUndoReducer(initial: string) {
const [state, dispatch] = useReducer(historyReducer, { past: [], present: initial, future: [] })
const set = useCallback((next: string) => dispatch({ type: 'set', next }), [])
return [state, set] as const
}
final state: {"past":["Ceramic Mug","Desk Lamp"],"present":"Trail Backpack","future":[]}
Nothing was lost. set only dispatches; it reads no state, so there’s nothing to go stale, and its dependency list is empty. React runs the reducer for each action in order with the current state.
(You could also fix the useState version by merging the three values into one object and using updater functions everywhere. It works, but you end up writing a reducer inside each callback.)
Reducers are plain functions
const s0: History = { past: ['Ceramic Mug'], present: 'Desk Lamp', future: [] }
log('historyReducer(s0, undo) →', historyReducer(s0, { type: 'undo' }))
historyReducer(s0, undo) → {"past":[],"present":"Ceramic Mug","future":["Desk Lamp"]}
No component, no hooks, no DOM: every transition can be unit-tested with a function call.
When useState is the better choice
A theme toggle is one independent value:
function useTheme() {
const [theme, setTheme] = useState<'light' | 'dark'>(() => (localStorage.getItem('theme') as 'light' | 'dark') ?? 'light')
useEffect(() => localStorage.setItem('theme', theme), [theme])
return [theme, setTheme] as const
}
Rewriting it with useReducer, action types and a switch adds ceremony and nothing else. Simplify that reducer to (prev, next) => next and you’ve reinvented useState.
Decision guide
| Situation | Use |
|---|---|
| One independent value | useState |
| Several values that always change together | useReducer |
| Next state depends on other state | useReducer (or one useState object + updater functions) |
| A fixed set of named transitions (idle → loading → success/error) | useReducer |
| You want to unit-test the transitions | useReducer |
| Passing “how to update” deep through context | useReducer (dispatch never changes) |
Both setters (setState and dispatch) keep the same identity across renders, so either is safe in dependency arrays and as a prop to memoized children.
Interview Q&A
When do you use useState and when useReducer?
useState for independent values; useReducer when several values change together, when the next state depends on other state, or when you want named, testable transitions. Start with useState and move when updates start reading each other.
Is useReducer more powerful than useState?
Structurally yes: useState is useReducer with a reducer that returns the new value (or calls it if it’s a function). Anything you can do with one, you can do with the other; the question is which reads better.
What bug appears when related state is split across several useStates?
Callbacks capture the values of the render that created them. Two updates before the next render both read the same snapshot and the second overwrites the first. Recorded: past ended as ["Ceramic Mug"] instead of ["Ceramic Mug", "Desk Lamp"]. A reducer gets the current state from React for each action, so it can’t happen.
Why is a reducer easier to test?
It’s a pure function: historyReducer(state, { type: 'undo' }) returns the next state, no rendering involved.
Why is dispatch handy to pass through context?
It never changes identity and it’s one entry point for every kind of update, so consumers that only dispatch don’t need to re-render when the state changes.
Related
- Building a Cart: the same history logic with
useState+ updater functions. - React Re-rendering: stale closures in general.
- Hooks Under the Hood (React Internals):
useStateisuseReducerinside React.
Sources
- Kent C. Dodds, Should I useState or useReducer? (the vault note this one is based on)
- react.dev:
useReducer, Extracting state logic into a reducer
Primary source: kentcdodds.com/blog/should-i-usestate-or-usereducer