In one minute
useReducer moves update logic out of event handlers into one pure function:
const [state, dispatch] = useReducer(reducer, initialArg, init?)
Handlers dispatch a plain action object that says what happened ({ type: 'added', id }). The reducer (state, action) => newState decides how the state changes. Because the reducer is a plain function, you can test it without React. And because dispatch never changes, you can pass it down without breaking memo.
You’ll be able to: model a cart as typed actions plus a reducer, wrap it in a custom hook, and explain what React does when the reducer returns the same state.
1// CartButtons (a memo component), on click:2dispatch({ type: 'added', id: 'p1' }) // mug3dispatch({ type: 'removed', id: 'p3' }) // backpack45function Cart() {6 const [cart, dispatch] =7 useReducer(cartReducer, initial)8 return (<>9 <FreeShippingNote total={total(cart)} />10 <CartButtons dispatch={dispatch} />11 </>)12}1314function cartReducer(state, action) {15 switch (action.type) {16 case 'added':17 return { items: [...state.items, newItem] }18 case 'removed':19 if (!inCart(state, action.id)) return state20 return { items: without(state, action.id) }21 }22}- event
dispatch({ type: 'added', id: 'p1' }) // mugThe shopper clicks Add mug. The handler doesn’t change anything itself: it only dispatches an action, a plain object that says what happened. - render phase
case 'added':React calls your reducer with the current state and that action. The'added'case returns a new object. - render phase
useReducer(cartReducer, initial)The new state isn’t the same object as the old one, soCartrenders with it, and so does its childFreeShippingNote.CartButtonsis skipped: it’smemo, anddispatchis the same function on every render. - commit phaseReact updates the page. Recorded:
Cart render: 1 items, $18.00, thenFreeShippingNote render.
1// CartButtons (a memo component), on click:2dispatch({ type: 'added', id: 'p1' }) // mug3dispatch({ type: 'removed', id: 'p3' }) // backpack45function Cart() {6 const [cart, dispatch] =7 useReducer(cartReducer, initial)8 return (<>9 <FreeShippingNote total={total(cart)} />10 <CartButtons dispatch={dispatch} />11 </>)12}1314function cartReducer(state, action) {15 switch (action.type) {16 case 'added':17 return { items: [...state.items, newItem] }18 case 'removed':19 if (!inCart(state, action.id)) return state20 return { items: without(state, action.id) }21 }22}- event
dispatch({ type: 'removed', id: 'p3' }) // backpackThe shopper clicks Remove backpack, but the cart has no backpack. - render phase
if (!inCart(state, action.id)) return stateThe'removed'case finds nothing to remove and returnsstateitself: the same object. - render phase
useReducer(cartReducer, initial)Carthas already run (React runs the reducer while rendering it). Now React sees the same state object and bails out: no children render. - commit phaseNothing changes on the page. Recorded: only
Cart renderwas logged, noFreeShippingNote render.
The example: the cart
The cart supports four things: add a product, remove one, change a quantity, and clear the cart. With useState, that’s four handlers that each copy and patch the items array. With a reducer, it’s one switch.
1. Actions as a TypeScript union
type CartItem = { id: string; qty: number }
type CartState = { items: CartItem[] }
type CartAction =
| { type: 'added'; id: string }
| { type: 'removed'; id: string }
| { type: 'quantityChanged'; id: string; qty: number }
| { type: 'cleared' }
Each action has a type string, and TypeScript narrows on it. Inside case 'quantityChanged', action.qty exists. Inside case 'cleared', it doesn’t.
2. The reducer
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'added': {
const inCart = state.items.some((i) => i.id === action.id)
if (!inCart) return { items: [...state.items, { id: action.id, qty: 1 }] }
return { items: state.items.map((i) => (i.id === action.id ? { ...i, qty: i.qty + 1 } : i)) }
}
case 'removed':
if (!state.items.some((i) => i.id === action.id)) return state // nothing to do: same object
return { items: state.items.filter((i) => i.id !== action.id) }
case 'quantityChanged':
if (action.qty <= 0) return cartReducer(state, { type: 'removed', id: action.id })
return { items: state.items.map((i) => (i.id === action.id ? { ...i, qty: action.qty } : i)) }
case 'cleared':
return state.items.length ? { items: [] } : state
default:
throw new Error(`Unknown action: ${(action as { type: string }).type}`)
}
}
Notice the two return state lines. When an action changes nothing, the reducer hands back the same object, and that matters below.
3. Testing it without React
The reducer is just a function, so call it:
const empty: CartState = { items: [] }
const one = cartReducer(empty, { type: 'added', id: 'p1' })
const two = cartReducer(one, { type: 'added', id: 'p1' })
const same = cartReducer(two, { type: 'removed', id: 'p3' })
log('one →', one)
log('two →', two)
log('same === two →', same === two)
log('empty unchanged →', empty)
one → {"items":[{"id":"p1","qty":1}]}
two → {"items":[{"id":"p1","qty":2}]}
same === two → true
empty unchanged → {"items":[]}
No component and no rendering. empty wasn’t mutated: every change built a new object.
4. A custom hook with lazy initialization
function loadCart(key: string): CartState {
log(` loadCart("${key}")`)
const saved = localStorage.getItem(key)
return saved ? JSON.parse(saved) : { items: [] }
}
function useCart() {
const [cart, dispatch] = useReducer(cartReducer, 'cart', loadCart)
useEffect(() => localStorage.setItem('cart', JSON.stringify(cart)), [cart])
const count = cart.items.reduce((n, i) => n + i.qty, 0)
const total = cart.items.reduce((sum, i) => sum + i.qty * byId(i.id).priceCents, 0)
return { cart, count, total, dispatch }
}
The third argument is an initializer: React calls loadCart('cart') once, on mount. A cart was saved with two lamps, then “Add mug” was clicked twice:
loadCart("cart")
Cart render: 2 items, $78.00
CartButtons render
FreeShippingNote render
Cart render: 3 items, $96.00
FreeShippingNote render
Cart render: 4 items, $114.00
FreeShippingNote render
loadCart ran once. Compare calling it yourself:
const [cart, dispatch] = useReducer(cartReducer, loadCart('cart'))
loadCart("cart")
Cart render: 2 items, $78.00
CartButtons render
FreeShippingNote render
loadCart("cart")
Cart render: 3 items, $96.00
FreeShippingNote render
loadCart("cart")
Cart render: 4 items, $114.00
FreeShippingNote render
The result is the same, but localStorage is read and parsed on every render, and the value is thrown away after the first one.
5. The components
function Cart({ useCartHook = useCart }) {
const { cart, count, total, dispatch } = useCartHook()
log(`Cart render: ${count} items, ${formatUSD(total)}`)
return (
<section className="cart">
<h2 id="summary">
{count} items · {formatUSD(total)}
</h2>
<ul>
{cart.items.map((i) => (
<li key={i.id}>
<span>
{byId(i.id).name} × {i.qty}
</span>
<button onClick={() => dispatch({ type: 'quantityChanged', id: i.id, qty: i.qty - 1 })}>−</button>
</li>
))}
</ul>
<CartButtons dispatch={dispatch} />
<FreeShippingNote total={total} />
</section>
)
}
function FreeShippingNote({ total }: { total: number }) {
log('FreeShippingNote render')
return <p>{total >= 5000 ? 'Free shipping!' : `${formatUSD(5000 - total)} to free shipping`}</p>
}
const CartButtons = memo(function CartButtons({ dispatch }: { dispatch: Dispatch<CartAction> }) {
log('CartButtons render')
return (
<p>
<button id="add-mug" onClick={() => dispatch({ type: 'added', id: 'p1' })}>Add mug</button>
<button id="add-lamp" onClick={() => dispatch({ type: 'added', id: 'p4' })}>Add lamp</button>
<button id="remove-backpack" onClick={() => dispatch({ type: 'removed', id: 'p3' })}>Remove backpack</button>
<button id="clear" onClick={() => dispatch({ type: 'cleared' })}>Clear</button>
</p>
)
})
Recorded, click by click. Mount:
loadCart("cart")
Cart render: 0 items, $0.00
CartButtons render
FreeShippingNote render
“Add mug”:
Cart render: 1 items, $18.00
FreeShippingNote render
“Remove backpack” (no backpack in the cart):
Cart render: 3 items, $75.00
“Clear”, then “Clear” again:
Cart render: 0 items, $0.00
FreeShippingNote render
Cart render: 0 items, $0.00
What to notice:
CartButtonsrendered once, on mount. It’s wrapped inmemo, and its only prop isdispatch, which is the same function on every render.- When the reducer returned the same object,
Cartstill ran, butFreeShippingNotedidn’t. React runs the reducer while renderingCart, so it only finds out “nothing changed” afterCarthas started. It then bails out: it skips the children, and nothing on the page changes. - “Clear” on an empty cart behaved the same way, because the
'cleared'case returnsstatewhen there’s nothing to clear.
6. A typo in an action type
dispatch({ type: 'emptied' } as unknown as CartAction)
log('dispatch() returned normally')
loadCart("cart")
dispatch() returned normally
ErrorBoundary caught: Unknown action: emptied
dispatch itself didn’t throw. It only queued the action. The reducer threw while React was rendering, so the nearest error boundary caught it (Error Boundaries). The TypeScript union normally prevents this typo; the as unknown as cast was needed to get past it.
How it works
dispatchqueues, the reducer computes. Eachdispatch(action)is added to the hook’s update queue. On the next render, React runsreducer(state, action)for each queued action in order, each one getting the previous result. This is why a reducer never reads stale state (the bug in useState vs useReducer).- Same state → bail out. If the final state is
Object.is-equal to the old one, React skips the component’s children and doesn’t commit. Returnstateitself when nothing changes, and a new object only when something does. - Reducers must be pure. No mutation, no
localStorage, no fetching. In development, Strict Mode calls your reducer twice to catch impure ones. Side effects belong in effects or event handlers. - Under the hood,
useStateisuseReducerwith a built-in reducer,(state, action) => typeof action === 'function' ? action(state) : action(Hooks Under the Hood).
Common mistakes
- Mutating state in the reducer (
state.items.push(…); return state). It returns the same object, so React bails out and nothing updates. - Calling the initializer yourself:
useReducer(r, loadCart('cart'))instead ofuseReducer(r, 'cart', loadCart). - Putting side effects in the reducer (saving, logging to a server). Use an effect, as
useCartdoes withlocalStorage. - One reducer for unrelated values. Independent values are simpler as separate
useStates (useState vs useReducer).
Interview Q&A
What's the signature of useReducer, and what does each part do?
const [state, dispatch] = useReducer(reducer, initialArg, init?). reducer(state, action) returns the next state. initialArg is the initial state, or the argument passed to init. dispatch(action) queues an action for the next render.
When is a reducer better than several useStates?
When values must change together, or one update depends on another value. A cart with add, remove, change-quantity and clear is a good example: one reducer keeps every transition in one place. Truly independent values are simpler as separate useStates.
How do you test a reducer?
Call it. It’s a pure (state, action) => newState function, so a test passes a state and an action and checks the result. Recorded: cartReducer(empty, { type: 'added', id: 'p1' }) returned a cart with one mug, and empty was unchanged.
What happens when the reducer returns the same state object?
React bails out: the children don’t render and the page doesn’t change. Recorded: “Remove backpack” on a cart without one logged Cart render but no FreeShippingNote render. The component itself still ran, because React computes reducer state while rendering it.
Why is dispatch useful to pass down?
It has a stable identity: the same function for the component’s whole life. A memoized child that receives only dispatch never re-renders because of it. Recorded: CartButtons rendered once, on mount.
What's the third argument for?
A lazy initializer, called once with initialArg on mount. Use it for expensive setup like reading localStorage. Recorded: loadCart ran once over three renders, but on every render when called directly.
How do you get type safety for actions?
Model them as a discriminated union on type. TypeScript narrows action in each case, so only the fields that action has are available, and a misspelled type is a type error.
Related
- useState vs useReducer: when to choose which, and the stale-closure bug.
- Building a Cart: the same cart built with
useStateupdater functions. - Context with use: share
dispatchwith distant components. - Hooks Under the Hood: the update queue both hooks use.
Sources
- react.dev:
useReducer, Extracting state logic into a reducer - Topic order inspired by Kent C. Dodds’ EpicReact Advanced React APIs workshop; the example app and code here are this handbook’s own.
Primary source: react.dev/reference/react/useReducer