State is a snapshot
GoByte Skills #14: In React, count is not a live variable. Each render gets its own constant, and a click handler only ever sees the render it was born in. That is why setCount(count + 1) three times adds one, and how the updater fixes it.
Arthur is one of GoByte's characters. This post was drafted by AI agents in Arthur's voice, then fact checked, run and edited by the GoByte team.
Each render sees the state it was given, which is why a stale closure is the model, not a bug. count is not a live variable. It is a constant that belongs to one call of your component.
Transcript
React code types in while renders appear as numbered frames, a click handler holds frame zero and sends three updates that all say one, and only the updater version climbs to three.
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
function addThree() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
console.log("this render saw", count);
}
return <button onClick={addThree}>{count}</button>;
}
Click once. The console prints this render saw 0 and the button shows 1, not 3.
The mechanism#
A component is a function React calls to get a snapshot of the UI. useState hands that call the current value, and count is a const inside it like any other local. setCount(count + 1) does not touch count: it queues an update and asks for another render. All three calls compute 0 + 1, so the queue says "set to 1" three times. The handler is a closure over render one, and render one is all it will ever see.
Ask for the next value, not this one#
Pass an updater and React runs the queue in order, feeding each function the result of the previous one:
setCount((c) => c + 1); // three of these: 3
Batching#
Three updates do not mean three renders. We counted: the click above renders once, and two updates inside a setTimeout also render once (measured on React 19; batching outside event handlers arrived in React 18). The queue is processed in the next render, after your handler has returned, so the handler could not see the result even if it wanted to.
Where it bites#
A timer or fetch callback started in render N logs render N's values, even if the user clicked five more times since. An effect with a missing dependency sees the snapshot of the render it last ran in (with [], the first one, forever), which is why the hooks lint rule asks for every dependency. When you truly need the latest value outside rendering (a timer, a socket handler), keep it in a useRef: one mutable box shared by all renders, and writing to it does not re-render.
Rule of thumb#
If the next state depends on the previous one, use the updater. If code runs later, assume it lives in the past. A closure over state is a photo, not a webcam.
Your product here? Partner with us
Back to topDiscussion
No comments yet. Signed in GoByte members with a verified e-mail can join. Community guidelines
Reading is open to everyone. Commenting and voting need a GoByte account with a verified e-mail.