Concept
The beginner framing: state is data a component owns that can change over time, when it changes, the UI showing it updates automatically.
The precise mental model: useState doesn't give you a mutable variable, it gives you a value frozen for this specific render, plus a setter function that, when called, tells React "please re-run this component function again, and this time give it this new value instead." Calling the setter does not change anything about the current, already-running render, it schedules a future call to your component function.
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // still logs the OLD value, this render's `count` never changes
}
return <button onClick={handleClick}>{count}</button>;
}Nothing about count inside this specific call to Counter ever changes, it's a plain const. Clicking the button schedules an entirely new call to Counter(), and that call receives the incremented value as its own, brand-new count. This is why console.log(count) right after calling setCount still shows the old value: you're still inside the render that scheduled the update, not the one that received it.
Click the button a few times. Notice ChildA and ChildB flash and their "rendered N×" counters climb too, even though neither ever received a new prop. React re-renders a component's entire subtree by default whenever its state changes; nothing opts a child out automatically.
Batching: multiple state updates, one re-render
function handleClick() {
setCount(count + 1);
setFlag(true);
// React 18+: both updates are BATCHED, only ONE re-render happens,
// with both new values applied together, not two separate renders.
}Since React 18, updates are automatically batched in virtually every context (event handlers, promises, timeouts, native event listeners), calling multiple setters in a row schedules exactly one re-render reflecting all of them, not one render per call. This is a performance optimization, but it also means you should never rely on a re-render happening synchronously between two setter calls in the same function.
Functional updates: when new state depends on old state
setCount(count + 1); // reads `count` from THIS render's closure, safe once, risky if called multiple times
setCount((prev) => prev + 1); // reads whatever the LATEST pending value actually is, always correctfunction handleTripleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// Result: count goes up by only 1! All three calls close over the SAME
// `count` from this render, each one computes "current + 1" independently.
}
function handleTripleClickFixed() {
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
// Result: count goes up by 3, each functional update receives the
// PREVIOUS update's result, not this render's stale snapshot.
The functional form exists specifically to avoid this, reach for it whenever a new state value is computed from the previous one.
Try It
Predict what the count is after one click, before checking the solution.
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setTimeout(() => {
setCount(count + 1);
}, 1000);
}
return <button onClick={handleClick}>{count}</button>;
}
// user clicks the button 3 times, quickly, all within one secondSolution
The count ends up at 1, not 3, even though the button was clicked three times.
Each click's setTimeout callback is a closure over that specific render's count, which was 0 for all three clicks (since none of the renders had completed yet within that first second). All three timeouts fire with setCount(0 + 1), three calls that all compute the same result, not three cumulative increments. This is the exact same stale-closure trap as the synchronous triple-click example above, just with a delay added. The fix is identical: setCount((prev) => prev + 1) in the timeout callback would correctly reach 3.
Implement It Yourself
Build a radically simplified useState to see the "slot" mechanism hooks actually use, a classic interview exercise:
let hookStates = [];
let hookIndex = 0;
function useState(initialValue) {
const currentIndex = hookIndex; // capture THIS hook's slot before any nested calls change it
if (hookStates[currentIndex] === undefined) {
hookStates[currentIndex] = initialValue;
}
function setState(newValue) {
hookStates[currentIndex] =
typeof newValue === "function" ? newValue(hookStates[currentIndex]) : newValue;
render(); // re-run the component from the top
}
hookIndex++;
This is a genuinely close approximation of how React's hooks work internally: state lives in an array outside your component (attached to the underlying fiber, not to a local variable), indexed by call order, and every re-render walks that array from the beginning, which is exactly why hooks must always be called in the same order, every render, with no conditionals around them.
Under the Hood
Every render is a fresh call to your component function (Execution Context), which means every const [count, setCount] = useState(0) line runs again from scratch, creating a fresh count binding and a fresh setCount closure each time. The reason the stale-closure bugs above happen at all is Closures working exactly as designed: handleClick's inner functions close over that specific render's count binding, permanently, there is no shared, mutable "count" variable anywhere for them to read a later value from. React re-runs your function to get a new closure with a new value; it doesn't (and structurally can't) reach back in and mutate an old one.
Common Mistakes
1. Mutating state directly instead of calling the setter
const [items, setItems] = useState([]);
items.push(newItem); // ❌ mutates the array, React never finds out anything changed
setItems(items); // same reference as before, React's Object.is check sees NO change, skips re-rendersetItems([...items, newItem]); // ✅ a genuinely NEW array reference, React detects the changeReact decides whether to re-render (for a given piece of state) partly by checking whether the new value is reference-different from the old one. Mutating in place keeps the same reference, so even if you call the setter, React may conclude nothing changed.
2. Assuming setState updates the variable synchronously
function handleClick() {
setCount(count + 1);
console.log(count); // ❌ still the OLD value, this render's `count` is a const, never reassigned
}Covered above, the setter schedules a future render; it never mutates the current render's binding.
3. Forgetting the functional-update form when depending on the previous value
Covered above, multiple setCount(count + 1) calls in the same handler don't stack, because each one independently reads the same stale count. Use setCount(prev => prev + 1) whenever the new value depends on the old one.
Best Practices
- Use the functional update form (
prev => ...) whenever new state depends on the current state, it's never wrong, even in the simple case, and it's the only correct option when multiple updates might batch together. - Never mutate state values in place, always create a new array/object (
[...arr],{...obj}) so React can detect the change by reference. - Keep state minimal, if a value can be computed from existing state or props during render, don't also store it as separate state; derive it instead, so there's only one source of truth to keep in sync.
- Colocate state as close as possible to where it's used, lifting everything to a top-level component "just in case" causes far more of the tree to re-render than necessary (see Rendering Lifecycle).
Performance Tips
- Every
setStatecall (that actually changes the value) triggers a re-render of that component and, by default, its entire subtree, see the live demo above. Keeping state colocated close to where it's needed limits the size of the subtree that re-renders on every update. - Storing something as state when it could be derived during render (e.g., a
filteredItemsstate kept "in sync" with aitemsstate via an effect) is a common source of both bugs (two sources of truth drifting out of sync) and unnecessary re-renders (the effect itself causes an extra render). Compute it directly in the render body instead:const filteredItems = items.filter(...). - React's
Object.isreference check on state updates is why the mutation mistake above isn't just incorrect, it can also make aReact.memo'd child think nothing changed and skip a re-render it actually needed.
