useState & functional updates
- Predict what repeated setState calls in one handler produce, given state is a per-render snapshot
- Choose the functional updater form whenever the next state is computed from the previous state
- Fix stacked updates that lose increments by rewriting setX(x + 1) as setX(prev => prev + 1)
- Write a lazy initializer,
useState(() => compute()), so expensive setup runs only on the first render
Read
Use the functional updater when new state depends on old
FlaguseState's setter accepts either a value or a function. When the next value is
computed from the previous one, pass a function — it receives the latest state.
// ❌ reads a possibly-stale snapshot
setCount(count + 1);
// ✅ receives the latest value
setCount((c) => c + 1);
Rule of thumb: if the new value mentions the old value, use setX((prev) => ...).
State is a snapshot; batching only decides how many renders
FlagInside an event handler, count is the value from the render that created the handler — a
snapshot. Calling the setter doesn't change that variable; it queues an update for the next
render.
function handleClick() {
setCount(count + 1); // count is 0 here: queues "replace with 1"
setCount(count + 1); // ...and still 0 here: queues "replace with 1" again
}
// next render: count = 1, not 2
The 1 comes from the snapshot: both calls computed 0 + 1. Batching is a separate
fact. React waits until the handler has finished and then applies every queued update in a
single re-render (since React 18, updates in timeouts, promises and native event listeners
are batched too, not just React event handlers). Batching decides how many times React
renders, not which value wins.
To make updates compound, queue a function instead of a value. React calls each one with the result of the update before it:
setCount((c) => c + 1); // 0 → 1
setCount((c) => c + 1); // 1 → 2
Stale state in async callbacks
FlagA function created during a render sees that render's state, for as long as it lives. If it
runs later — after an await, in a timeout, from a subscription — the value it reads may be
several updates old.
function SaveButton() {
const [saves, setSaves] = useState(0);
async function handleSave() {
await api.save();
setSaves(saves + 1); // ❌ `saves` is from the render that handled the click
}
return <button onClick={handleSave}>Saved {saves} times</button>;
}
Click twice before the first save returns. Nothing has re-rendered between the clicks, so both
run the same handler from the same render, both read saves = 0, and both set 1. Two saves,
and the counter says 1.
The updater form doesn't read the closure at all. React calls it with the latest queued value:
setSaves((s) => s + 1); // ✅ 0 → 1 → 2
Quiz
count starts at 0. One click handler calls setCount(count + 1) three times. What is count on the next render, and why?
When should you use the functional updater form, setX(prev => ...)?
A component declares const [todos, setTodos] = useState(createInitialTodos()). How often does createInitialTodos run?
Fill in the blank
Predict the output
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count);
}
return <button onClick={handleClick}>{count}</button>;
}function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 5);
setCount((c) => c + 1);
}
return <button onClick={handleClick}>{count}</button>;
}Challenge
Fix the counter that loses updates
FlagCounter has two buttons, and both lose updates.
Add 3 calls setCount(count + 1) three times — but count is a fixed snapshot for
that render (0), so all three calls queue the same update: "set to 1". React applies the
three in one re-render (that's batching), and the count lands on 1.
Save +1 waits for a pretend server call, then adds 1. Click it twice before the first save comes back and the count goes up by 1, not 2.
Your task
Edit Counter.tsx so the tests pass:
- Clicking Add 3 increases the count by 3
- Two clicks reach 6
- Two quick Save +1 clicks add 2 once both saves finish
- The counter still starts at 0