Render, commit and state snapshots
What really happens when you set state, why state looks frozen inside a handler, and how to queue several updates.
Here's a puzzle. This button calls setCount(count + 1) three times. What number do you expect after one click? Try it.
It only goes up by 1 per click. That surprises almost everyone the first time. To understand why, you need a clear picture of what React does when you set state. Once you have it, a whole class of confusing bugs becomes obvious.
Trigger, render, commit
Every time your UI changes, React goes through three steps. A restaurant is a good way to picture it: your components are cooks in the kitchen, and React is the waiter.
- Trigger: someone places an order. A render is triggered for one of two reasons:
- the app starts for the first time (the initial render, when
createRoot(...).render(<App />)runs), or - a component's state changes because you called a setter.
- the app starts for the first time (the initial render, when
- Render: the kitchen prepares the order. React calls your component functions to find out what the UI should look like. On the initial render it calls the root component. On a re-render it calls the component whose state changed, and then its children, since their output may depend on it.
- Commit: the dish goes to the table. React updates the browser's DOM (the page's live tree of elements) to match. On a re-render it only changes the DOM nodes that are actually different.
"Rendering" in React means calling your component function. It does not mean "painting pixels." That's the browser's job, which happens after the commit.
You can see that React only touches what changed. Type something in the box below, then click the button a few times. Each click re-renders App, but your text stays put. React compares the new JSX with what's on the screen, sees that the <input> is the same element as before, and leaves it alone. Only the heading text changes.
State as a snapshot
Back to the puzzle. The key fact is this:
Setting state doesn't change the variable you already have. It requests a new render.
When React calls your component, useState hands you the state for that render. Your JSX, your event handlers and every variable in that function all see that same value. It's like a photo taken at the moment of the render. That's why we call it a snapshot.
So during the render where count is 0, the click handler is really doing this:
setCount(0 + 1); // "next render, count should be 1"
setCount(0 + 1); // "next render, count should be 1"
setCount(0 + 1); // "next render, count should be 1"Inside that handler, count is 0 the whole time. You asked for "1" three times, so the next render has count equal to 1.
The snapshot even survives a delay. In the next example, the handler sets state and then, three seconds later, writes what it sees into a log. Click "+5" and watch.
The screen shows the new number right away, but the delayed log prints the old value. That handler belongs to the old render, and in that render number never changed. Each render has its own props, state and event handlers, fixed in time.
This is a feature, not a bug. Imagine a chat app with a "Send" button that waits a few seconds before sending. If you switch the recipient during that wait, you'd still want the message to go to the person you chose when you clicked. Snapshots give you that for free.
Batching: one render for many updates
There's one more detail in the puzzle. You called a setter three times, but React only re-rendered once. That's batching: React waits until all the code in your event handler has finished before it processes your state updates and re-renders.
Think of a waiter who doesn't run to the kitchen after every word you say. They wait until you've finished ordering, then take the whole order at once. That's good for performance, and it means you never see a half-updated screen where one state variable changed and another hasn't yet.
Two setters run in one click, and React does one render with both changes applied together. You will never see "Grace Lovelace" flash on the screen in between. Since React 18, this batching happens everywhere: in event handlers, timeouts, promises and so on.
Queueing updates with updater functions
So how do you make "+3" actually add 3? Instead of passing the next value, pass a function that calculates the next value from the previous one:
setCount(c => c + 1);This is an updater function. It tells React "whatever the count is by the time you get to this, add one." React puts each updater in a queue. During the next render, it runs them in order, feeding each one the result of the one before.
Here's how React works through the queue when count starts at 0:
| Queued update | c receives | Returns |
|---|---|---|
c => c + 1 | 0 | 1 |
c => c + 1 | 1 | 2 |
c => c + 1 | 2 | 3 |
React stores 3 as the final value and the next render shows it.
Mixing values and updaters
You can mix the two kinds in one handler. A plain value means "replace with this." An updater means "do this to whatever's there." What does this show after one click, starting from 0?
setCount(count + 5); // replace with 0 + 5
setCount(c => c + 1); // then add 1
setCount(42); // then replace with 42React processes the queue in order: replace with 5, then 5 + 1 gives 6, then replace with 42. The answer is 42. The last plain value wins, and only updaters that come after it can build on it.
Deep diveNaming, purity and Strict Mode
Naming. The usual convention is to name the updater's argument after the first letter of the state variable: setCount(c => c + 1), setEnabled(e => !e). Some people prefer prevCount or the full name. Any is fine; be consistent.
Purity. Updater functions run during rendering, so they must be pure: take the previous value, return the next one, and do nothing else. Don't set other state or log from inside one.
Strict Mode. The sandboxes on this site, like a fresh Vite app, wrap your app in <StrictMode>. In development only, Strict Mode calls your component functions and updater functions twice to help you find impure code. If you ever see a log from render appear twice, that's why. It doesn't happen in production builds.
When should you use an updater?
If the new state depends on the old state, an updater is the safe choice: setCount(c => c + 1), setLiked(l => !l), setBooks(b => [...b, newBook]). In a simple handler that sets state once, setCount(count + 1) works fine too, and you'll see both styles in real code. Reach for the updater whenever you update the same state more than once in a handler, or the update happens later, like inside a setTimeout.
Fix the delayed counter
This "pending orders" counter should go up by one immediately when you click Buy, then back down three seconds later when the order completes. Click Buy several times quickly. The count goes wrong, and can even go below zero. Fix it.
Show solution
Each timeout callback was created during an older render, so it saw an old snapshot of pending and completed. It then set state to "old value minus one," which threw away other clicks. Updater functions don't read the snapshot at all. They receive the latest queued value, so every click and every timeout is counted correctly.
Predict the queue
Without running it, predict what the number shows after one click, starting from 0. Then paste it into a sandbox and check.
setNumber(number + 5);
setNumber(n => n + 1);
setNumber(n => n * 2);Show solution
The answer is 12. React processes the queue in order:
| Queued update | n receives | Returns |
|---|---|---|
replace with 5 (since number is 0) | (unused) | 5 |
n => n + 1 | 5 | 6 |
n => n * 2 | 6 | 12 |
count is 0. A click handler runs setCount(count + 1) three times. What is count on the next render?
What does React mean by rendering a component?
Which call correctly adds 1 to the score, even when called several times in one handler?
Day 3 progress