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?
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>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"
| Step | Snapshot flagged | Queue | Output |
|---|---|---|---|
| render 1 | 0 | — | screen "Flagged for review: 0" |
| 3 calls | 0 | [→1, →1, →1] (changed) | — (changed) |
console.log | 0 | [→1, →1, →1] | 0 (changed) |
| handler returns; the batch is processed | — (changed) | 1 → 1 → 1 = 1 (changed) | — (changed) |
| render 2 | 1 (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)
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>n => n + 1 the queue processes 0 → 1 → 2 → 3, and render 2 shows 3.How it works
Step 1 of 6
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

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?
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?
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.
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"?
function queueTwo() { setQueue([...queue, 'ep2']); // line 1 setQueue([...queue, 'ep3']); // line 2}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?
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])Go deeper
Pro tips
- 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: 1for two setters, and line D showsrenders: 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.
- 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, thenscreen: 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
- react.dev, "Queueing a Series of State Updates": https://react.dev/learn/queueing-a-series-of-state-updates. Covers the three
setNumber(number + 1)calls giving 1, the updater queue giving 3, and batching. Fetched and confirmed; valid as of 2026-09-27. - react.dev,
useStatereference: https://react.dev/reference/react/useState. Covers the setter updating only the next render,Object.isskipping the component's children ("may still need to call your component"), and batching after all handlers run. Fetched and confirmed; valid as of 2026-09-27. - Supporting, for automatic batching in promises and
setTimeout: https://react.dev/blog/2022/03/29/react-v18 (fetched 2026-09-27).
Related lessons
Checked against the tools and docs as of 2026-09-27.