State is a snapshot
The React mental model where each render freezes its own state, and the updater form for working with the latest value instead.
Here is a bug report a junior on your team might file:
My “+3” button calls
setCount(count + 1)three times in the click handler, but the counter only goes up by one per click. IssetStatebroken?
It is not broken, and the handler reads count exactly the way React intends.
To see why, recall this chapter’s two rules.
UI = f(state) makes your component a function of its inputs, and the purity contract makes that function deterministic: same inputs, same output, no reaching outside.
If render is a function and count is one of its inputs, then count cannot change midway through a render.
It is a fixed input, a constant.
By the end of this lesson you will look at that handler and know, before you click, that it adds one, plus the reflex every React codebase reaches for to make it add three.
You have met the tool already: const [count, setCount] = useState(0) declares a piece of local state, where reading count gives this render’s value and calling setCount(next) schedules a re-render.
This lesson studies one property of that value: it is a snapshot.
The rest of useState comes in the next chapter.
A render is a snapshot of state
Section titled “A render is a snapshot of state”When React renders your component, it takes the current value of each piece of state and bakes it into that render as a plain constant.
For the rest of that render count is not a live variable; it is a fixed number, and it holds that one value everywhere: in the JSX you return and in every function you define along the way, every event handler and every callback.
If count was 3 when React called your function, count is 3 throughout that render.
Calling setCount(...) does not reach back and change it.
The setter has one job: ask React to render again and produce a new snapshot with the new value.
Think of each render as a photograph. React points the camera at your state, presses the shutter, and hands you a still frame. Everything in it, the props, the state, the handlers wired to your buttons, is fixed. When state changes, React does not edit the photo; it takes a new one.
This follows from the two rules you already trust.
A pure function handed count = 3 must always produce the same tree, so it cannot have count be 3 on one line and 4 three lines later in the same call.
That is why state changes between renders, never during one.
Recall the phase strip from the first lesson of this chapter, Trigger → Render → Reconcile → Commit.
The frozen count lives inside one Render box; the new snapshot is produced in the next Render box, after a fresh Trigger.
Scrub through the sequence below to follow one click from the render that holds count = 0 to the render that holds count = 1.
count = 0
click handler
setCount(0 + 1)
next photo
not taken yet
Nothing queued. This render owns its own frozen count.
count = 0
click handler ran
setCount(0 + 1)
still the same
photo
queuedset count = 1 — scheduled, not applied
count = 0
previous photo
setCount(0 + 1)
count = 1
click handler
setCount(1 + 1)
new snapshotcount is now 1 for the whole render
Three setters add only one
Section titled “Three setters add only one”With the model in hand, look again at the bug. Here is the handler from the report:
function handleTripleClick() { setCount(count + 1); setCount(count + 1); setCount(count + 1);}count is a snapshot. In this render it is one fixed number, say 0, and all three lines read that same 0.
function handleTripleClick() { setCount(count + 1); setCount(count + 1); setCount(count + 1);}So every line is really setCount(0 + 1), which is setCount(1). Three requests, all asking for the same thing: set the count to 1.
function handleTripleClick() { setCount(count + 1); setCount(count + 1); setCount(count + 1);}React grants the request. The next render’s snapshot holds 1, not 3. The reads never saw each other’s writes; they all looked at the same photo.
You did not write “add one, three times.” You wrote “set the count to one” three times, and React obliges.
Batching and the update queue
Section titled “Batching and the update queue”You can predict the bug now. Here is why the snapshot survives three setters intact. Two mechanisms work underneath; start with the simpler one.
The first is the update queue. When you call setCount(...), React does not re-render on the spot. It writes an entry onto a small queue it keeps for that piece of state and keeps running your handler. Only after the handler finishes does React work through the queue to compute the final next value, then render once.
What each entry says is the subtle part. When you pass a value, as in setCount(1), the entry means “replace the state with 1,” overriding whatever was queued before it. So three setCount(0 + 1) calls leave three “replace with 1” entries; React applies them in order, and they all land on the same 1. That is the bug, stated mechanically. An updater entry behaves differently, and that difference is the fix.
The second mechanism is batching . React groups all the setters fired during one event into a single re-render: click the button, fire three setters, get one new render, not three. This is why the snapshot is stable across the three calls. React never pauses between them to re-read state, so count has no chance to change underneath you. Since React 18, and unconditionally in React 19, this also covers setters fired later, inside promises, setTimeout, and async callbacks, so batching is the same everywhere with nothing to configure.
One boundary is worth naming. React batches the setters within a single event, but not across separate events. Two real clicks are two renders, because the grouping covers everything one event triggers, not everything that ever happens.
Now compare what gets queued when you pass a value versus a function. The three calls and the button are the same in both; watch how the queue fills and what it resolves to.
update queue
next render
collapseeach entry replaces the last — all three land on 1
count is 0), and a value
entry just replaces. Three identical “replace with 1” entries all land on the
same 1.
update queue
each receives the last result
next render
threadeach function consumes the previous output — they stack to 3
0 → 1 → 2 → 3.
The left tab is the bug; the right tab is the fix, shown next as code.
The updater form reads from the queue, not the snapshot
Section titled “The updater form reads from the queue, not the snapshot”When the next state depends on the previous one, reading count from the frozen snapshot gives you a stale value by design: by the time React processes the queue, that render is long gone. You want the value the queue has computed so far, and the updater form is how you get it. Instead of a value, you pass a function: setCount((c) => c + 1). React calls it with the pending value, the result of any updaters already queued ahead, and stores what you return. Queue three and React threads them: 0 → 1 → 2 → 3.
function handleTripleClick() { setCount(count + 1); setCount(count + 1); setCount(count + 1);}Adds one per click. Every line reads the same snapshot of count, so all three resolve to the same value. Three identical “replace” entries collapse into one.
function handleTripleClick() { setCount((c) => c + 1); setCount((c) => c + 1); setCount((c) => c + 1);}Adds three per click. Each updater receives the running result of the one before it, 0 → 1 → 2 → 3, instead of re-reading the frozen count. The argument c is the pending value, not the render’s snapshot.
That gives us the rule, but it is conditional, so resist the urge to round it up to “always use updaters.”
The updater carries the same purity rules you already met. It must be pure: take the previous state, return the next one, no side effects, no reaching outside for values. React also reserves the right to call it more than once in development to surface impurity early, so a sloppy updater that mutates a shared object will misbehave in ways that only appear in dev. Keep updaters pure and the issue never arises.
Now try it yourself. This counter has a “+3” button wired with the value form, so it stubbornly adds one per click. Switch it to the updater form and watch the jump from +1 to +3.
This button is supposed to add 3 per click, but it only adds 1. Fix it by switching the three setter calls to the updater form, then Run and click to confirm it now jumps by 3.
Show the fix
Read each updater’s argument c as “the value the queue has reached so far,” not the frozen count. That is what threads the three calls 0 → 1 → 2 → 3 instead of landing all on 1.
const handleClick = () => { setCount((c) => c + 1); setCount((c) => c + 1); setCount((c) => c + 1);};State you set now, you read on the next render
Section titled “State you set now, you read on the next render”The snapshot rule has a corollary that trips up most people the first time: the setter is queued, not immediate, so the line after it still sees the old value.
setCount(count + 1);console.log(count); // still the OLD count — this render's snapshotThe console.log runs on the next line but inside the current render, whose count is frozen at the old value. The new value does not exist yet. It appears only in the next render’s snapshot, after React processes the queue and calls your component again, so the log prints the old number.
This corollary has its own corollary: setCount returns nothing. There is no const next = setCount(count + 1), because the setter does not hand you the new state. To use the next value, you read it on the next render.
Predict what this program prints, then press Check.
This is the click handler from a component where, in the current render, count === 0. The user clicks once, so the body runs top to bottom. What does it print?
// inside a render where count === 0, the click handler runs:console.log(count);setCount(count + 1);console.log(count);count is frozen at 0. setCount schedules a re-render rather than reassigning count, so the second log still reads the snapshot. The 1 exists only in the next render.Stale closures: setters that run after the render
Section titled “Stale closures: setters that run after the render”So far, each snapshot was read inside the render that produced it. The rule sharpens when a setter runs after the render finishes: timers, promises, and network callbacks are all defined in one render but run later, against a page that has moved on.
Picture a button whose handler schedules an increment a second from now:
setTimeout(() => setCount(count + 1), 1000);Captures the snapshot. The callback closes over count from the render that created the timeout. Click twice fast and both callbacks capture the same count, so two clicks land as one increment.
setTimeout(() => setCount((c) => c + 1), 1000);Reads the live queued value. The updater ignores the captured snapshot and asks React for the current pending value when it runs. Two clicks, two increments, however stale the render that scheduled them.
The cause is a closure . The arrow function you hand to setTimeout remembers the count from the render that created it: the snapshot again, just delayed. It fires a second later holding render #0’s number, even if the page is now on its third render.
The updater form fixes this the same way it fixed the triple-click: setCount((c) => c + 1) never reads the captured count. It asks React for the current value when it runs, so it cannot be stale.
Updating objects and arrays without mutating
Section titled “Updating objects and arrays without mutating”A setter can also appear to do nothing: you call it and the screen does not change. This is the spread reflex from the purity lesson, now applied at the setState call site. Recall React’s equality rule from this chapter’s first lesson: it decides whether state changed using Object.is , which compares primitives by value and objects by reference.
Here is the trap:
user.name = 'Alice';setUser(user); // no re-render — same reference, Object.is bails outYou changed the data, but you handed setUser the same object reference it already had. Object.is sees the identical reference, concludes nothing changed, and skips the render. This is the most disorienting variant: the debugger shows the new value, yet the UI is frozen.
The fix: never mutate what is in state, and always hand the setter a new object or array. Here are the three idioms you will reach for constantly.
setUser({ ...user, name: 'Alice' });setItems([...items, newItem]);setItems(items.filter((item) => item.id !== id));Update an object: spread the old fields into a fresh {}, then override the one you are changing. New reference, so Object.is sees a change and React re-renders.
setUser({ ...user, name: 'Alice' });setItems([...items, newItem]);setItems(items.filter((item) => item.id !== id));Append to an array: spread the old items into a new array and add the new one. The original is untouched.
setUser({ ...user, name: 'Alice' });setItems([...items, newItem]);setItems(items.filter((item) => item.id !== id));Remove from an array: filter returns a new array without the item; map does the same for replacing one. Both produce a new reference for free.
One nuance trips people at nested state: spread is shallow. Spreading the top level gives a new outer object, but the nested objects inside are still the same references. To change something one level down, spread at every level on the way to it:
setUser({ ...user, address: { ...user.address, city: 'Berlin' } });The flip side is occasionally useful on purpose: setting state to the same reference is a deliberate no-op, and React correctly skips the render. The bug is when that no-op happens by accident, because you mutated in place and handed back the old reference. Same mechanism, opposite intent.
For state nested deeply enough that hand-spreading every level turns into noise, the ecosystem’s answer is Immer, a library that lets you write what looks like a mutation and produces an immutable update underneath; recognize the name when you see it.
Now drill the instinct: given a setter call, does it re-render or quietly bail out? Sort each chip.
For each call, decide whether React re-renders or bails out via `Object.is`. Assume `user`, `items`, `count`, and `isOpen` already hold values. Drag each item into the bucket it belongs to, then press Check.
setUser({ ...user, name: 'Al' })setItems([...items, newItem])setCount(count + 1)setIsOpen(!isOpen)user.name = 'Al'; setUser(user)items.push(newItem); setItems(items)setCount(count)flushSync: forcing a synchronous render
Section titled “flushSync: forcing a synchronous render”React 19 batches every update, but occasionally you set state and then, on the very next line, need the DOM to already reflect it, usually to measure a layout or hand the updated node to a non-React library.
With normal batching the DOM has not updated yet on that next line.
flushSync is the opt-out: it renders and commits to the DOM synchronously, before the next line runs.
import { flushSync } from 'react-dom';
flushSync(() => setSelectedId(id));// the DOM is committed here — safe to read or measure the new nodenode.scrollIntoView();You will rarely need it.
Choosing the shape of your state
Section titled “Choosing the shape of your state”For several related values, do you give each its own useState, or bundle them into one useState holding an object?
Take a form with a first and last name: two pieces of state, const [firstName, setFirstName] = useState('') and const [lastName, setLastName] = useState(''), or one, const [name, setName] = useState({ first: '', last: '' }).
The choice comes down to whether the values share a lifetime.
- Use separate state when the values change independently.
A panel’s
isOpenand the search text below it have nothing to do with each other. You write more setters, but each update is a clean primitive set. - Use grouped object state when the values change together: a form draft, a settings bundle, anything you read and write as a unit.
You write fewer setters, but every update is now a spread (
{ ...form, email }), carrying the shallow-spread discipline from the previous section.
So you trade the number of setters against spread ceremony.
Once a grouped object accumulates three or more setters that always move in concert, with rules about how one field constrains another, reach for useReducer, which gives those transitions a single named home.
External resources
Section titled “External resources”The two React documentation pages below map almost one-to-one onto this lesson and are the canonical reference. The two visual guides beside them animate the same model when words on a page stop landing. When the snapshot model feels slippery, and it does for a while, these are the pages to revisit.
Why a render freezes its state, with the same counter example worked in detail.
How the update queue resolves value vs. updater entries, the mechanism behind the +3 fix.
Animated walkthrough of rendering: watch each render capture its own snapshot of props, state, and handlers.
Diagrams for why state survives across renders and why a setter is queued, not immediate.