Must knowHooks · 08

useState vs useReducer

Independent values → useState; values that change together → useReducer. The stale-closure bug that decides it.

On this page · 7 sections
  1. In one minute
  2. The example: “Recently viewed” with undo
  3. When useState is the better choice
  4. Decision guide
  5. Interview Q&A
  6. Related
  7. Sources

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.

Three useStates
RecentlyViewed.tsx
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'), [])
What you see
past: [] · present: “Ceramic Mug”
  1. effectsuseEffect(() => set('Desk Lamp'), [])The first effect calls set. It was created in render 1, so it reads past = [] and present = "Ceramic Mug".
  2. effectssetPast([...past, present]) // values from render 1It queues past: ["Ceramic Mug"] and present: "Desk Lamp".
  3. effectsuseEffect(() => set('Trail Backpack'), [])The second effect calls the same function, with the same old snapshot. It also queues past: ["Ceramic Mug"], overwriting the first.
  4. render phaseRecorded final state: Desk Lamp is missing from the history.

0 / 0
A “recently viewed” history with undo. Two effects record two products right after mount: three related values updated from a closure lose an entry; a reducer can’t (both recorded).

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.

Sources

Primary source: kentcdodds.com/blog/should-i-usestate-or-usereducer