open atlas
↑ Back to track
React, zero to senior RCT · 01 · 03

setState is a request: snapshots, update queues, and automatic batching

setState is a request queued on the fiber, not an assignment: state is a per-render snapshot, so two setCount(count+1) calls increment once. React 18 batches everywhere — promises, timeouts, native handlers — one render per batch; flushSync forces a sync flush at real cost.

RCT Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

The loyalty team found it during a ledger reconciliation: 4,112 redemptions where the backend charged users for a points bundle but the UI — and the follow-up request built from UI state — recorded only the smaller deduction. The code had passed review twice. The combo-redemption handler did the obvious thing: setPoints(points - cost) for the base item, then, when a bonus applied, setPoints(points - bonusCost) right below it. Every engineer who read it saw two subtractions. React saw two requests to replace state, both computed from the same snapshot — points was 500 in both lines, so the queue held “replace with 400” followed by “replace with 450”, and the second silently won. Single redemptions worked, QA tested single redemptions, and the combo path shipped. The fix was a four-character change per line — setPoints(p => p - cost) — but the team only understood it after someone explained that state in React is not a variable you write to; it is a snapshot you read from, and setState is a message to the next render, not an assignment to the current one.

State is a snapshot; setState is a request

If you’ve ever called setState twice and seen only one update, the reason is right here. A render works with a frozen copy of state. When React calls your component, useState returns the values as of that render, and every closure created during it — event handlers included — captures those values forever. Calling setPoints does not change points; it cannot, because points is a const in a function call that already finished or is about to. What setPoints(next) actually does is append an update to a queue on the hook’s fiber and schedule a re-render. Only when that next render runs does React reduce the queue and hand your component the new value.

That is the entire mechanism behind the most-asked React interview question. In a handler where count is 0, calling setCount(count + 1) three times enqueues “replace with 1” three times — each line read the same snapshot. The next render reduces the queue: 1, 1, 1. Final value: 1, one render. The mental model “setState is slow/async assignment” is wrong in a way that matters: it is not a delayed write, it is not a write at all. It is a request for a future render computed from whatever you passed in — and what you passed in was computed from a stale-by-design snapshot.

Updater functions: sending instructions instead of values

The queue accepts two kinds of entries: a replacement value, or an updater function. setPoints(p => p - cost) enqueues the function itself; during the next render React reduces the queue in order, feeding each updater the result of the previous entry. That makes a sequence of updaters compositional where replacement values are not:

function Redeem({ cost, bonusCost }) {
  const [points, setPoints] = useState(500);

  function redeemCombo() {
    // Bug: both lines read the same snapshot (points = 500).
    // Queue: [replace 400, replace 450] — the second wins. Result: 450.
    setPoints(points - cost);       // cost = 100
    setPoints(points - bonusCost);  // bonusCost = 50
  }

  function redeemComboFixed() {
    // Queue: [p => p - 100, p => p - 50], reduced in order:
    // 500 -> 400 -> 350. Result: 350, still exactly one render.
    setPoints((p) => p - cost);
    setPoints((p) => p - bonusCost);
  }
}

Mixing forms follows the same reduction: with count at 0, the sequence setCount(c => c + 1), setCount(42), setCount(c => c + 1) reduces 0 → 1 → 42 → 43. The replacement discards everything before it; updaters build on whatever precedes them. The same snapshot logic explains stale closures outside handlers: an interval created on mount captures that first render’s count forever, so setCount(count + 1) inside it pins the value at 1 — setCount(c => c + 1) reads the live queue state instead and counts correctly. The rule that falls out: any update derived from the previous state must use the updater form. It is not a style preference; it is the only form the queue can compose.

Why this works

Why did React choose snapshot semantics at all, instead of making setState write immediately so the next line reads the new value? Because immediate writes would tear the render. Your JSX, your handlers, and your effects from one render must all describe one consistent moment — if state could mutate mid-render, two parts of the same screen could render from different values, the exact torn-UI class of bug React’s atomic commit exists to prevent. Snapshots also make handlers predictable under async gaps: an alert shown three seconds after a click reports the values as of that click, not whatever the world mutated into since. The queue-plus-updater design is the price of that consistency — and it is also what makes batching safe, because React knows every pending update is either a value or a pure function it can reduce at render time.

Automatic batching: what React 18 actually changed

Batching is React collapsing multiple setState calls into one render and one commit. React 17 batched only inside React event handlers — the synthetic-event call stack it controlled. The moment your code left that stack, batching ended: in a promise .then, a setTimeout, a native addEventListener, each setState triggered its own synchronous render-and-commit pass. Three setStates in a fetch callback meant three full renders back-to-back — and code in the wild silently depended on that, reading the freshly committed DOM between two setState lines.

React 18 with createRoot made batching automatic everywhere: handlers, promises, timeouts, native events — all updates in one tick are queued and flushed in a single render. The performance delta is mechanical: a fetch callback setting loading, data, and pagination state went from 3 render+commit passes to 1 — on a heavy page, the difference between one 40 ms frame and three. The migration failure mode is the inverse: code that relied on the React 17 flush-per-call — measure the DOM after setState in a timeout, or a test asserting intermediate renders — now reads stale DOM, because the commit happens after the whole callback finishes. Updates inside transitions batch separately from urgent updates, but within each lane the rule holds: one flush per batch.

flushSync: the escape hatch and its bill

Sometimes you genuinely need the DOM updated now, mid-handler: append a chat message and scroll the list to it, focus an input that the next render will create, populate state before the browser’s print dialog snapshots the page. flushSync(fn) runs the updates inside fn and synchronously forces render and commit before returning — the next line of your handler sees the committed DOM.

import { flushSync } from "react-dom";

flushSync(() => {
  setMessages((prev) => [...prev, newMessage]);
});
// DOM is committed: the new <li> exists, scroll targets the real node.
listRef.current.lastChild.scrollIntoView({ behavior: "smooth" });

The bill is exactly what batching saved you: each flushSync is a full synchronous render+commit on the main thread, inside your handler. Three flushSync calls re-create React 17’s worst case, except now deliberately. It defeats batching for those updates, blocks the handler until commit finishes, forces layout earlier than the browser would have, and can fire effects synchronously. The senior heuristic: flushSync is for imperative DOM reads that must observe the new state — scroll, focus, measure, print — and nothing else. If you reach for it to “fix” a stale value in your own JavaScript logic, the actual fix is an updater function or deriving the value locally; flushSync there is a 40 ms band-aid over a 4-character bug.

Quiz

count is 0. A click handler runs: setCount(c => c + 1); setCount(42); setCount(c => c + 1). What is count on the next render, and how many renders happen?

Quiz

After upgrading to React 18 with createRoot, a fetch .then callback sets three pieces of state and then immediately measures a DOM element's height. The measurement is now wrong. What changed?

Recall before you leave
  1. 01
    Why do two consecutive setCount(count + 1) calls increment once, and what precisely does the updater form change about the queue reduction?
  2. 02
    What exactly did automatic batching change in React 18, what production code breaks on upgrade, and when is flushSync the right tool versus a band-aid?
Recap

React state is a snapshot, not a variable: each render receives frozen values, every closure made during that render captures them permanently, and setState changes nothing in the current render — it appends an entry to an update queue on the hook’s fiber and schedules a re-render. Two setCount(count + 1) calls therefore enqueue the same replacement value twice and increment once, which is how a loyalty UI under-deducted points on every combo redemption while passing review twice. The queue holds replacement values and updater functions and is reduced strictly in order at render time: replacements discard everything before them, updaters receive the previous entry’s result and compose — 0 then updater, 42, updater yields 43. Any update derived from previous state must use the updater form; it is also the fix for stale closures in intervals and async callbacks. Batching collapses all updates in one tick into a single render and commit: React 17 did this only inside React event handlers and flushed synchronously per call everywhere else, while React 18 with createRoot batches promises, timeouts, and native listeners too — three commits become one, and upgrade casualties are exactly the code that read the DOM between intermediate flushes. flushSync is the explicit opt-out, forcing a synchronous render and commit so the next line can scroll, focus, measure, or print against the new DOM — at the price of everything batching bought; using it to patch a stale value in plain logic instead of an updater function is paying a render to avoid a four-character fix. Now when you see two setState calls that should add up but only apply the last one, you’ll reach for the updater form first — and when a DOM read comes back stale after a React 18 upgrade, you’ll know exactly which escape hatch to open.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.