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.
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}- event
setQty(qty + 1) // firstIn this renderqtyis1, so this queues “replace with 2”. - event
setQty(qty + 1) // secondqtyis still1in this closure (state doesn’t change during the handler), so this also queues “replace with 2”. - render phase
const [qty, setQty] = useState(1)React processes the queue: 2, then 2 again. One of the two increments was lost.
1function Quantity() {2 const [qty, setQty] = useState(1)3 function addTwo() {4 setQty(q => q + 1) // first5 setQty(q => q + 1) // second6 }7 return <button onClick={addTwo}>Add 2 (now {qty})</button>8}- event
setQty(q => q + 1) // firstThis queues a function, not a value. - event
setQty(q => q + 1) // secondAnd another one. - render phase
const [qty, setQty] = useState(1)React runs them in order, passing each one the result of the previous: 1 → 2 → 3. Recorded button:Add 2 (now 3).
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
- Lazy initial state reads and parses
localStorageonce, on mount (Managing UI State). Thetry/catchsurvives corrupted or hand-edited storage. - An effect writes it back whenever
statechanges (Side Effects). Because every update creates a new object,[state]changes exactly when the cart does. - History is an array of snapshots plus
step, the one being shown.itemsis derived:history[step]. - Undo only moves
step. The later snapshot stays until you make a new change, which first drops everything afterstep(history.slice(0, step + 1)). That’s why the third snapshot (the second mug) is gone from the saved history: adding the lamp after Undo replaced that future.
Common mistakes
setX(x + 1)several times in one handler, or in a timer/promise created in an earlier render.push,splice,sortor property assignment on state, followed bysetState(sameObject).- Storing derived values (
items,canUndo) next to the state they come from. - Appending to a history without dropping the undone future.
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].
Related
- useState vs useReducer: the same history logic as a reducer.
- React Re-rendering: closures and stale values.
Sources
- react.dev: Updating state based on the previous state, Updating arrays in state, Queueing a series of state updates
- Kent C. Dodds, useState lazy initialization and function updates
- Topic order inspired by Kent C. Dodds’ EpicReact React Hooks workshop (its tic-tac-toe capstone); the example app and code here are this handbook’s own.
Primary source: react.dev/reference/react/useState#updating-state-based-on-the-previous-state