Skip to content

useState: hook slots, snapshots and batching

A render reads a fixed snapshot of state; a setter queues a change for the next render, and it never edits the current one.

Predict first

Before you read: predict

ExamHall candidate app. The question navigator has a Flag all unanswered button. The current section has three unanswered questions (Q4, Q7, Q9), and nothing is flagged yet.

The candidate clicks the button once.

What does the console log, and what does the button show afterwards?

Code for this question
const [flagged, setFlagged] = useState(0); function flagAllUnanswered() {  for (const q of ['Q4', 'Q7', 'Q9']) {    setFlagged(flagged + 1);  }  console.log(flagged);}// <button onClick={flagAllUnanswered}>Flagged for review: {flagged}</button>
Pick one

The 30-second version

State is a constant within one render: a handler reads the same value before and after its setter. A setter only queues an entry. A value (setX(x + 1), computed from that constant) means "replace with this"; a function (setX(n => n + 1)) is applied to the running result. After the handler, React applies the queue in order and renders once.

A render reads a fixed snapshot of state; a setter queues a change for the next render, and it never edits the current one.

Step through the code

Trace of the opening question

Play options

Ready: press Play or step with Next frame

Row 1 of 5 · render 1

Starting row · computed by comparing each row with the one before

  • Snapshot flagged: 0
  • Queue: —
  • Output: screen "Flagged for review: 0"

Rows are not linked to mechanism steps.

Step
render 1
Snapshot flagged
0
Queue
—
Output
screen "Flagged for review: 0"
Trace of the opening question
StepSnapshot flaggedQueueOutput
render 10—screen "Flagged for review: 0"
3 calls0[→1, →1, →1] (changed)— (changed)
console.log0[→1, →1, →1]0 (changed)
handler returns; the batch is processed— (changed)1 → 1 → 1 = 1 (changed)— (changed)
render 21 (changed)— (changed)screen "Flagged for review: 1" (changed)

Space plays or pauses · ←/→ one row · Home/End first/last

Rows come from the pack's own trace table.

The probe code (rows are not linked to code lines)
The probe code
const [flagged, setFlagged] = useState(0); function flagAllUnanswered() {  for (const q of ['Q4', 'Q7', 'Q9']) {    setFlagged(flagged + 1);  }  console.log(flagged);}// <button onClick={flagAllUnanswered}>Flagged for review: {flagged}</button>
With n => n + 1 the queue processes 0 → 1 → 2 → 3, and render 2 shows 3.

How it works

Step 1 of 6

State lives on the component, not in your variable. Each useState takes the next hook slot by call order, so never call hooks conditionally.

Analogy: A court clerk's docket (finance and legal: court filing)

The judge works only from the case file printed that morning. Motions filed during the hearing go into the clerk's tray. At session close the clerk processes the tray in order and prints one new file.

In the analogy In React
The printed case file the judge reads all session This render's state snapshot
Filing a motion puts it in the tray; the printed file does not change A setter queues an update; the variable does not change
"Set the count of exhibits to 4", written after reading 3 in the printed file setX(x + 1): a replacement value computed from the snapshot
"Add one to whatever the count is when this motion is processed" setX(n => n + 1): an updater applied to the pending value
The clerk processes the whole tray at session end, in order, and prints once Batching: one queue pass and one render per batch
A motion that leaves the file exactly as it was produces no new print for the courtrooms The Object.is bailout: children are not re-rendered

Breaks down when:

  • A clerk judges whether two files say the same thing; React compares identity (Object.is). Wrong inference: "a copied object with equal fields is skipped."
  • A clerk opens nothing for a no-change motion; React may call the component once before bailing out. Wrong inference: "an equal set never runs the component body."

Contrasting case: a shared spreadsheet cell edited live: the next reader sees the write, with no tray and no reprint. That is a ref (useRef().current): reads see writes at once, and nothing re-renders.

In real terms: setters enqueue updates on the component's hook, React processes the queue after the current code finishes and renders once, and each render's handlers read that render's state.

Examples, run for real

Example 1: ExamHall EXH-418, "a double-click on Flag for review counts as 1"

async function onFlag() {
  await saveFlag('Q7');                 // setTimeout-backed promise, 10 ms
  setFlagged(flagged + 1);              // BUG. MR !702: setFlagged(n => n + 1)
  log.push(`after setter, this handler still sees flagged=${flagged}`);
}
BUG  setFlagged(flagged + 1):
  render flagged=0
  after setter, this handler still sees flagged=0
  after setter, this handler still sees flagged=0
  render flagged=1
  screen: Flagged: 1
FIX  setFlagged(n => n + 1):
  render flagged=0
  after setter, this handler still sees flagged=0
  after setter, this handler still sees flagged=0
  render flagged=2
  screen: Flagged: 2

The snapshot survives the await (step 2); the updater fixes the count (step 4).

Example 2: chat header, batching outside React events and the bailout

Runs without act(), so render counts are the real scheduler's. ChatHeader holds unread and typing and renders a child, MessageList.

setTimeout(() => { setUnread(u => u + 1); setTyping(true); }, 0);              // A
(async () => { await Promise.resolve(); setUnread(u => u + 1); setTyping(false); })(); // B
setTimeout(() => setTyping(false), 0);  // C, then D: typing is already false
A timeout: setUnread(u => u + 1) + setTyping(true) -> ChatHeader renders: 1, MessageList renders: 1 | screen: unread=1 typing=true
B promise: await, then setUnread(u => u + 1) + setTyping(false) -> ChatHeader renders: 1, MessageList renders: 1 | screen: unread=2 typing=false
C same value: setTyping(false) while typing is false -> ChatHeader renders: 1, MessageList renders: 0 | screen: unread=2 typing=false
D same value again: setTyping(false) -> ChatHeader renders: 0, MessageList renders: 0 | screen: unread=2 typing=false

A and B: two setters in a timeout, or after an await, give one render (step 5). C: an equal value called ChatHeader once but skipped its child; D did no work (step 6).

Picture it

a person at a desk studies one developed photograph of a still life. Beside it, several paper slips sit in an open envelope, not yet applied. The photograph stands for the render's snapshot, and the slips for queued updates.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 5

(p1-payments-queue.mjs, 50 runs, 1 distinct output)

Checkout total state is 10. One click runs setTotal(t => t * 2); setTotal(total + 5);.

What total is shown after the click?

Pick one

Question 2 of 5

(p2-streaming-bailout.mjs, no act(), 50 runs, 1 distinct output)

Player holds quality = '720p' and settings = { captions: true } and renders a child, <PlayerControls>. A setQuality('720p') · B setSettings({ ...settings }) · C setSettings(settings).

Which of these re-render PlayerControls?

Pick one

Question 3 of 5

(p3-ticketing-fill-slot.mjs, 50 runs, 1 distinct output)

One click starts two seat holds from the same render: onClick={() => { hold(); hold(); }}, where async function hold() { await reserveSeat(); setHeld(____); }. Fill the slot so the counter ends at 2.

Pick one.

Pick one

Question 4 of 5

A watch queue starts as ['ep1']. Its "Queue next two" button runs:

One click shows "ep1, ep3".

Which single change makes one click show "ep1, ep2, ep3"?

Code for this question
function queueTwo() {  setQueue([...queue, 'ep2']);   // line 1  setQueue([...queue, 'ep3']);   // line 2}
Pick one

Question 5 of 5

An event page can reload its seat map:

The buyer clicks Refresh. The map never flickers and no new fetch is sent.

What is the bug?

Code for this question
function EventPage() {  const [open, setOpen] = useState(true);  function refreshMap() {    setOpen(false);   // unmount the seat map…    setOpen(true);    // …and mount it again so it fetches fresh seats  }  return (<>    <button onClick={refreshMap}>Refresh</button>    {open && <SeatMap eventId="e-7" />}  </>);}// SeatMap fetches its seats in useEffect(() => { … }, [eventId])
Pick one

Go deeper

Pro tips
  1. Count renders by logging in the component body, and compare the count with the setter calls.
    • When it helps: checking whether updates were batched (step 5) or bailed out (step 6).
    • Line A shows ChatHeader renders: 1 for two setters, and line D shows renders: 0.
    • Status: RAN (React 19.3.0, Node 24.19.0). React DevTools' "Highlight updates when components render" is the in-browser equivalent, but it was not exercised here.
  2. When the handler needs the new value now, compute it once: const next = flagged + 1; setFlagged(next); send(next).
    • When it helps: analytics or an API call right after a setter (step 3).
    • It prints analytics got next=1, state variable still=0, then screen: Flagged: 1.
    • Status: RAN. This fixes the read. It does not replace the updater form when several updates can come from one snapshot (EXH-418).
Sources

Checked against the tools and docs as of 2026-09-27.