Skip to content
Chapter 23Lesson 4

State is a snapshot

The React mental model that each render freezes its own snapshot of state, and the updater form that lets you work with the latest value instead.

Here is a bug report a junior on your team might file, almost word for word:

My “+3” button calls setCount(count + 1) three times in the click handler, but the counter only goes up by one per click. Is setState broken?

It is not broken. The handler is reading count exactly the way React intends, and the surprising part is that the correct behavior is the one that trips everyone up. To see why, recall the two rules from earlier in this chapter. UI = f(state) says your component is a function of its inputs. The purity contract said that function must be deterministic: same inputs, same output, with no reaching outside. Put those together and one consequence follows. If render is a function and count is one of its inputs, then count cannot change in the middle of a render. It is a fixed input, a constant.

That single consequence is what this lesson is about. By the end you will look at that handler and know, before you click, that it adds one, and you will know the reflex that every React codebase leans on to make it add three instead.

You have already met the tool. Earlier in the chapter you saw that const [count, setCount] = useState(0) declares a piece of local state: reading count gives you this render’s value, and calling setCount(next) schedules a re-render. We will not re-open the full useState story here. Typing it, lazy initialization, and what it is and is not for all come in the next chapter. This lesson studies exactly one property of that value: it is a snapshot.

Here is the model, worth settling in your mind before you see any fix.

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 duration of that render, count is not a live variable you can watch change. It is a number that was fixed the moment the render began, and it keeps that one value everywhere: in the JSX you return, and in every function you define along the way, including every event handler and every callback. If count was 3 when React called your function, then count is 3 everywhere in that render. Calling setCount(...) does not reach back into the render you are standing in and change it. The setter has exactly 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 you get a still image. Everything in that photo, the props, the state, and the handlers wired to your buttons, is fixed in that one frame. When state changes, React does not edit the photo; it takes a new one.

This is not a quirk bolted onto React. It follows directly from the two rules you already trust. A pure function handed count = 3 must always produce the same tree, so it would break purity for count to be 3 on one line and 4 three lines later within the same call. That is why React holds a firm line: state changes between renders, never during one.

That line is the heart of the lesson, and most of what follows restates it from a new angle. 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, the next photo, is produced in the next Render box, after a fresh Trigger.

The sequence below makes that visible. Scrub through it to follow one click from the render that holds count = 0 through to the render that holds count = 1.

RENDER #0

count = 0

click handler

setCount(0 + 1)

next photo
not taken yet

Nothing queued. This render owns its own frozen count.

React calls your component. It freezes count at 0 into this render and wires up the handler with that value already baked in.
RENDER #0

count = 0

click handler ran

setCount(0 + 1)

still the same
photo

queuedset count = 1 — scheduled, not applied

You click. The handler runs entirely inside render #0's snapshot, so it still sees count as 0. setCount(1) is scheduled, not applied — the photo does not change.
RENDER #0

count = 0

previous photo

setCount(0 + 1)

RENDER #1

count = 1

click handler

setCount(1 + 1)

new snapshotcount is now 1 for the whole render

React re-renders. A new render, a new snapshot — render #1 holds count = 1. The old photo is gone; the setter produced the next card rather than editing the first.

Keep this sequence in mind, because the next two sections are both careful readings of these three cards.

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.

1 / 1

Read it through the snapshot model and the mystery clears up. In this render count is 0, so setCount(count + 1) is setCount(0 + 1) is setCount(1) on all three lines. You did not write “add one, three times.” You wrote “set the count to one” three times. React obliges, and the next render shows 1: three reads of the same photo can only ever see the same number.

This snippet is a classic React interview question, because it separates the people who think of state as a live variable from the people who think of it as a snapshot.

Before fixing it, predict it. After one click on that “+3” button, what does the counter show?

count starts at 0, and the handler is the three-line setCount(count + 1) from above. After clicking the button once, the counter shows:

3
1
0

Why the snapshot holds: batching and the update queue

Section titled “Why the snapshot holds: batching and the update queue”

You can predict the bug now. The next question is the one an experienced engineer asks: why does the snapshot survive three setters intact? Two mechanisms work underneath, and we will take the simpler one first.

The first is the update queue. When you call setCount(...), React does not stop everything and 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.

The subtle part is what each entry says. When you pass a value, as in setCount(1), the entry means “replace the state with 1,” and it overrides whatever was queued before it. So three setCount(0 + 1) calls leave three “replace with 1” entries on the queue. React applies them in order, and they all land on the same 1. That is the bug, stated mechanically. Keep it in mind, because an updater entry behaves differently, and that difference is the fix in the next section.

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, and you get one new render, not three. This is why the snapshot is stable across those three calls: React never pauses between them to re-read state and start a fresh render, so count has no chance to change underneath you. Since React 18, and unconditionally in React 19, this grouping also covers setters fired later, inside promises, setTimeout, and async callbacks. So you get the same batching everywhere, not only in event handlers, and there is nothing to configure.

One boundary is worth naming. React batches the setters within a single event, but it does not batch across separate, intentional events. Two real clicks are two renders, because the grouping covers everything one event triggers, not everything that ever happens.

The turning point of this lesson is the difference between what gets queued when you pass a value versus when you pass a function. Compare the two in the following panels. The three calls and the button are the same in both; watch how the queue fills and what it resolves to.

setCount(count + 1) called ×3

update queue

1 replace → 1 count + 1 = 0 + 1
2 replace → 1 count + 1 = 0 + 1
3 replace → 1 count + 1 = 0 + 1

next render

count = 1

collapseeach entry replaces the last — all three land on 1

Every entry was computed from the same snapshot (count is 0), and a value entry just replaces. Three identical “replace with 1” entries all land on the same 1.

The left tab is the bug; the right tab is the fix. Here is that fix as code.

The updater form: read from the queue, not the snapshot

Section titled “The updater form: read from the queue, not the snapshot”

When the next state depends on the previous one, you do not want to read count from the frozen snapshot, because by the time React processes the queue, that snapshot is stale by design. You want to read the value the queue has computed so far, and that is exactly what the updater form gives you. Instead of passing a value, you pass a function: setCount((c) => c + 1). React calls that function with the pending value, which is the result of any updaters already queued ahead of it, and uses what you return as the next entry. Queue three of them 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. This is the buggy version.

That gives us the rule. It is conditional, so resist the urge to round it up to “always use updaters.”

Two constraints come along with the updater function, and they are the same purity rules you already met. The updater must be pure: it takes the previous state and returns the next one, with no side effects and no reaching outside for values. React also reserves the right to call your updater more than once in development, as a way to surface impurity early. A sloppy updater that mutates a shared object, for instance, will then misbehave in ways that only show up in the dev environment. You will see the mechanism behind that double call in a later chapter. For now, keep updaters pure and the issue never arises.

Now try it yourself. The following 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.

Preview
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 makes the three calls thread 0 → 1 → 2 → 3 instead of all landing 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 catches people the first day they reach for it: 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 snapshot

Walk it through the rule one more time. The console.log runs on the next line down, but it is still inside the current render, the render whose count is frozen at the old value. The new value does not exist yet. It will only appear 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 a corollary of its own, and it is the part people resist longest: setCount returns nothing. There is no const next = setCount(count + 1), because the setter is not a function that hands you the new state. If you want to use the next value, you read it on the next render. Often you did not need it in state at all and could compute it directly while rendering, but that is derived state, which the next chapter covers.

Predict this one. It is the cleanest deterministic case in the lesson.

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);

Stale closures: setters that run after the render

Section titled “Stale closures: setters that run after the render”

Everything so far has been one render’s snapshot read inside that same render. The rule gets sharper when a setter runs after the render has finished: timers, promises, and network callbacks are all code that was defined in one render but runs 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 where the timeout was created. Click twice fast and both timeouts captured the same count, so two clicks can land as a single increment. The value is a second old by the time it runs.

The reason is a closure . The arrow function you hand to setTimeout was created during a particular render, and it remembers that render’s count: the snapshot again, just delayed. When it fires a second later, the page may be on its third render, but the callback is still holding the number from render #0. If the user clicked twice before the first timeout fired, the second timeout also closed over that stale count, so both compute 0 + 1 and both set 1. Two clicks, one increment.

The updater form fixes it for the same reason it fixed the triple-click: setCount((c) => c + 1) does not read the captured count at all. It asks React for the current value at the moment it runs, so it cannot be stale. Now we can state the rule in its general form, the one the triple-click bug was only a special case of:

Updating objects and arrays without mutating

Section titled “Updating objects and arrays without mutating”

There is a second way a setter can appear to do nothing at all. Not “it added one instead of three,” but “I called it and the screen did not change.” This is the same skill you built in the purity lesson, now exercised at the setState call site. Recall React’s equality rule from the first lesson of this chapter: React decides whether state actually changed using Object.is . Primitives compare by value, objects and arrays by reference.

Here is the trap:

user.name = 'Alice';
setUser(user); // no re-render — same reference, Object.is bails out

You changed the data: user.name really is 'Alice' now. But you handed setUser the same object reference you already had. React compares the next state to the current with Object.is, sees the identical reference, concludes nothing changed, and skips the render. The data moved; the screen did not. This is the most disorienting variant, because everything looks right in the debugger (the object holds the new value) yet the UI is frozen.

The fix is the spread reflex you learned alongside purity, now applied at the call site: never mutate what is already 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 object, 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 array is untouched; the setter receives a brand-new array.

setUser({ ...user, name: 'Alice' });
setItems([...items, newItem]);
setItems(items.filter((item) => item.id !== id));

Remove from an array: filter returns a new array with the unwanted item gone. map works the same way for replacing one. Both produce a new reference for free.

1 / 1

Each idiom shares one shape: produce a new reference, never edit the old one. That is the entire trick, and Object.is does the rest.

One nuance trips people the first time they hit nested state: spread is shallow. Spreading the top level gives you a new outer object, but the nested objects inside are still the same references you started with. If the thing you are changing lives one level down, you spread at every level on the way to it:

setUser({ ...user, address: { ...user.address, city: 'Berlin' } });

The flip side is worth naming, because it is occasionally useful on purpose: setting state to the very same value or reference is a deliberate no-op React lets you lean on. Calling setUser(user) with an unchanged reference will correctly skip the render. The bug is when that no-op happens by accident, because you mutated in place and handed back the old reference when you meant to change something. Same mechanism, opposite intent.

For state nested deeply enough that hand-spreading every level turns into noise, the ecosystem’s answer is a small library called Immer, which lets you write what looks like a mutation and produces an immutable update under the hood. Recognize the name so it is not a mystery when you see it. The course default is direct spreads and updater functions, and you will reach for them far more often.

Now drill the instinct that ties it all together: given a setter call, does it cause a 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.

Re-renders A new value or reference reaches the setter
Bails out Same value or reference — no re-render
setUser({ ...user, name: 'Al' })
setItems([...items, newItem])
setCount(count + 1)
setIsOpen(!isOpen)
user.name = 'Al'; setUser(user)
items.push(newItem); setItems(items)
setCount(count)

Because React 19 batches everything, there are rare moments when batching is exactly what you do not want. The situation is this: you set some state and then, on the very next line, you need the DOM to already reflect it. The usual reason is to measure a layout or hand a freshly updated node to a non-React library. Normal batching means the DOM has not updated yet on that next line. flushSync is the opt-out: it runs the render and commits it to the DOM synchronously, before the next line executes.

import { flushSync } from 'react-dom';
flushSync(() => setSelectedId(id));
// the DOM is committed here — safe to read or measure the new node
node.scrollIntoView();

This is a recognition-level tool, not a workhorse. You will go a long time between legitimate uses.

Let’s close with a decision you will make on almost every component: when you have several related values, do you give each its own useState, or bundle them into one useState holding an object? The snapshot and immutability material you just learned is what makes this a real trade-off rather than a coin flip.

Compare a form with a first and last name. You could use 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 one question: do the values share a lifetime?

  • Use separate state when the values change independently. A panel’s isOpen and the search text below it have nothing to do with each other. You write more setters, but each update is a clean primitive set with no ceremony.
  • Use grouped object state when the values change together: a form draft, a settings bundle, anything you tend to read and write as a unit. You write fewer setters, but every update is now a spread ({ ...form, email }), and you carry the shallow-spread discipline from the previous section with you.

So it is a trade between the number of setters and spread ceremony, decided by whether the values live and die together. There is a point past which a grouped object outgrows even this. When its transitions start to multiply, say three or more setters that always move in concert, with rules about how one field constrains another, that is the signal to reach for useReducer, which gives those transitions a single named home. We will not teach it here; just know its name and the threshold that calls for it.

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.