Must knowAdvanced React APIs · 01

useReducer

A cart as one pure reducer with typed actions: dispatch, lazy init, testing without React, and when React bails out.

On this page · 7 sections
  1. In one minute
  2. The example: the cart
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

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.

Add mug
cart.tsx (simplified)
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}
What you see
Cart: 0 items · $0.00
Add mug
Remove backpack
$50.00 to free shipping
Rendered this step
Cart–
FreeShippingNote–
CartButtons (memo)–
  1. eventdispatch({ 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.
  2. render phasecase 'added':React calls your reducer with the current state and that action. The 'added' case returns a new object.
  3. render phaseuseReducer(cartReducer, initial)The new state isn’t the same object as the old one, so Cart renders with it, and so does its child FreeShippingNote. CartButtons is skipped: it’s memo, and dispatch is the same function on every render.
  4. commit phaseReact updates the page. Recorded: Cart render: 1 items, $18.00, then FreeShippingNote render.

0 / 0
A cart keeps its items in a reducer. Click a button and follow which line runs, what renders and what changes on screen (both runs recorded).

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:

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

Common mistakes

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.

Sources

Primary source: react.dev/reference/react/useReducer