useState: state is a snapshot
Why count + 1 three times adds one, how React batches every update in an event into one render, and when to pass an updater function instead of a value.
After this lesson you can
- Explain why a state variable does not change after calling its setter
- Predict the result of several setter calls in one event handler
- Choose between passing a value and passing an updater function
useState gives a component a value that survives between renders, and a
function to ask for a new one:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
console.log(count); // still 0
}
return <button onClick={handleClick}>{count}</button>;
}
One click shows 1, not 3, and the log prints 0. Both surprises have
the same cause.
A render sees one snapshot
Each render is one call of Counter. In that call, count is a const
with a fixed value, and every function created during the call — including
handleClick — closes over that value. Calling setCount does not touch
the count variable. It queues an update and asks React to render the
component again; the next call of Counter receives the new value. Inside
the handler, count is 0 on every line, so all three calls queue "replace
with 0 + 1".
Batching
React does not re-render after each setter call. It collects every update
queued during the event handler and processes them together in one render
after the handler returns. Since React 18 this automatic batching applies
everywhere — inside promises, setTimeout and native event listeners too,
not just React event handlers.
Value versus updater function
A setter accepts either a value or a function. A value means "replace the state with this". A function means "compute the next state from whatever the state is at this point in the queue". React applies the queue in order, feeding each updater the result of the step before:
// A setter call either queues a value (replace) or a function (updater).// On the next render React runs the queue in order.function processQueue(base, queue) { let state = base; for (const update of queue) { state = typeof update === "function" ? update(state) : update; } return state;} export function run() { const count = 0; // the snapshot this render's handler closed over return { threeValues: processQueue(count, [count + 1, count + 1, count + 1]), threeUpdaters: processQueue(count, [(c) => c + 1, (c) => c + 1, (c) => c + 1]), valueThenUpdater: processQueue(count, [count + 5, (c) => c + 1]), updaterThenValue: processQueue(count, [(c) => c + 1, 42]), };}The rule of thumb: if the next value depends on the previous one, pass an
updater (setCount(c => c + 1)). Updaters must be pure — in development
Strict Mode React calls them twice to surface side effects — so do not
push to arrays or fire requests inside one.
Initial state runs once, but its expression does not
useState(initial) uses initial only on the first render; afterwards it
is ignored. The expression is still evaluated on every render, though, so
useState(buildBigTable()) calls buildBigTable every time and throws the
result away. Pass the function instead — useState(buildBigTable) — and
React calls it only for the first render.
Common mistakes
- Reading state right after setting it.
setName(v); save(name)saves the old name. Usevdirectly, becausenamewill not change until the next render. - Incrementing with a value in a loop or a timer. Several
setCount(count + 1)calls in one event collapse into one increment, as the first scenario in the example models. - Side effects inside an updater. Strict Mode calls it twice in development, so the effect happens twice; updaters only compute.
useState(expensive())runsexpensiveon every render even though only the first result is used.
Try it yourself
2 visible tests · 5 hidden testsA profile form keeps one object in state:
const [person, setPerson] = useState(initial). One click handler
queues the updates in updates, in order. Each update is
{ "field": f, "value": v, "form": ... }, where form is:
"snapshot"—setPerson({ ...person, [f]: v }), spreading thepersonthis render closed over;"updater"—setPerson(p => ({ ...p, [f]: v })).
Implement profileClick(initial, updates) returning
{ "nextRender": <the object the next render receives>, "lostFields": [...] }.
lostFields lists every field named by at least one update whose value
in nextRender is not the value set by the last update naming that
field (a field missing from nextRender counts as lost), sorted
alphabetically. Values are strings or numbers.
profileClick({"city":"London","name":"Ada"}, [{"form":"snapshot","field":"name","value":"Grace"},{"form":"snapshot","field":"city","value":"Paris"}])profileClick({"city":"London","name":"Ada"}, [{"form":"updater","field":"name","value":"Grace"},{"form":"updater","field":"city","value":"Paris"}])
Sign up to run the tests, check the hidden ones and save your progress. Sign up