Must knowHooks · 07

Building a Cart

Updater functions, immutable updates, saving to localStorage and an undo history, in one cart.

On this page · 8 sections
  1. In one minute
  2. Lost updates: “Add 2”
  3. Mutation: the array that didn’t update
  4. The whole cart: saved, with undo
  5. Common mistakes
  6. Interview Q&A
  7. Related
  8. Sources

In one minute

This note puts the hooks so far together in one feature: a cart with quantity buttons, saved in localStorage, with undo. Three rules carry it. When the next state is computed from the previous one, pass an updater function (setQty(q => q + 1)), because the value in your handler is a snapshot of one render. Never mutate state: create a new array or object, or React sees the same reference and doesn’t re-render. And keep one piece of managed state; everything else (the visible items, whether Undo is enabled) is derived from it.

You’ll be able to: avoid lost updates, update arrays immutably, persist state, and build a history with undo.

setQty(qty + 1) ×2
Quantity.tsx
1function Quantity() {2  const [qty, setQty] = useState(1)3  function addTwo() {4    setQty(qty + 1) // first5    setQty(qty + 1) // second6  }7  return <button onClick={addTwo}>Add 2 (now {qty})</button>8}
What you see
Add 2 (now 1)
React’s queue for qty
first update–
second update–
  1. eventsetQty(qty + 1) // firstIn this render qty is 1, so this queues “replace with 2”.
  2. eventsetQty(qty + 1) // secondqty is still 1 in this closure (state doesn’t change during the handler), so this also queues “replace with 2”.
  3. render phaseconst [qty, setQty] = useState(1)React processes the queue: 2, then 2 again. One of the two increments was lost.

0 / 0
An “Add 2” button clicked once, with the quantity starting at 1 (both versions recorded).

Lost updates: “Add 2”

function QuantityStale() {
	const [qty, setQty] = useState(1)
	function addTwo() {
		setQty(qty + 1)
		setQty(qty + 1)
	}
	return <button id="add2" onClick={addTwo}>Add 2 (now {qty})</button>
}

One click, starting at 1:

Add 2 (now 2)
function QuantityUpdater() {
	const [qty, setQty] = useState(1)
	function addTwo() {
		setQty((q) => q + 1)
		setQty((q) => q + 1)
	}
	return <button id="add2" onClick={addTwo}>Add 2 (now {qty})</button>
}
Add 2 (now 3)

qty is a constant in each render. Both setQty(qty + 1) calls compute 2. Updater functions are queued and run in order, each receiving the result of the previous one. Rule of thumb: if the new value is calculated from the old one, use the function form; if you’re replacing it outright (setQty(1)), either form works.

Mutation: the array that didn’t update

function CartMutating() {
	const [items, setItems] = useState(['Ceramic Mug'])
	function add() {
		items.push('Desk Lamp') // changes the same array…
		setItems(items) // …so React sees the same reference and skips the render
		log('pushed; items.length =', items.length)
	}
	return <button id="addItem" onClick={add}>{items.length} items</button>
}

Clicked twice:

pushed; items.length = 2
pushed; items.length = 3
1 items

The array really has three items, but the button still says one. setItems(items) passes the same array, Object.is(old, new) is true, and React skips the render. Create a new array instead: setItems([...items, 'Desk Lamp']), or items.with(i, value), items.filter(…), items.toSorted().

The whole cart: saved, with undo

type CartState = { history: string[][]; step: number }

function readSavedCart(): CartState {
	log('readSavedCart() runs')
	try {
		const saved = JSON.parse(localStorage.getItem('cart') ?? 'null')
		if (saved && Array.isArray(saved.history)) return saved
	} catch {
		// corrupted storage: start fresh
	}
	return { history: [[]], step: 0 }
}

function Cart() {
	const [state, setState] = useState(readSavedCart)
	const items = state.history[state.step] // derived, never stored twice

	useEffect(() => {
		localStorage.setItem('cart', JSON.stringify(state))
	}, [state])

	function add(name: string) {
		setState(({ history, step }) => {
			const kept = history.slice(0, step + 1) // drop any undone future
			return { history: [...kept, [...kept[step], name]], step: kept.length }
		})
	}

	return (
		<>
			<button id="mug" onClick={() => add('Ceramic Mug')}>Add mug</button>
			<button id="lamp" onClick={() => add('Desk Lamp')}>Add lamp</button>
			<button id="undo" disabled={state.step === 0} onClick={() => setState((s) => ({ ...s, step: s.step - 1 }))}>
				Undo
			</button>
			<p id="items">{items.join(', ') || 'empty'}</p>
		</>
	)
}

The recorder added a mug, a lamp and another mug, pressed Undo, then added a lamp:

Ceramic Mug, Desk Lamp, Ceramic Mug
Ceramic Mug, Desk Lamp
Ceramic Mug, Desk Lamp, Desk Lamp

What was saved in localStorage:

{
  "history": [
    [],
    [
      "Ceramic Mug"
    ],
    [
      "Ceramic Mug",
      "Desk Lamp"
    ],
    [
      "Ceramic Mug",
      "Desk Lamp",
      "Desk Lamp"
    ]
  ],
  "step": 3
}

After reloading the page, the cart came back:

Ceramic Mug, Desk Lamp, Desk Lamp

And across the reload plus another click, readSavedCart ran once:

readSavedCart() runs

How the pieces fit

Common mistakes

Interview Q&A

Why use setQty(q => q + 1) instead of setQty(qty + 1)?

qty is the value from the render that created the handler; it doesn’t change when you call the setter. Two setQty(qty + 1) calls both compute the same value. The updater form is queued and receives the latest pending value. Recorded: “Add 2” from 1 gave 2 vs 3.

Why doesn't mutating an array and calling setItems(items) re-render?

React compares the new state with the old using Object.is. The same array reference means “no change”, so the render is skipped. Recorded: items.length = 3 but the button showed “1 items”. Always create a new array/object.

How do you persist state to localStorage?

Read it with a lazy initializer (useState(readSaved), so parsing happens once, inside try/catch), and write it in an effect that depends on the state.

How does undo history work?

Keep an array of past states and an index. Undo/redo move the index; a new change drops the states after the index and appends. The displayed state is derived from history[step].

Sources

Primary source: react.dev/reference/react/useState#updating-state-based-on-the-previous-state