You call the setter, log the state on the next line, and see the old value:
function handleClick() {
setCount(count + 1);
console.log('clicked, count is', count);
}clicked, count is 0The screen shows 1 a moment later, but the log says 0, and code that uses count after the setter (an API call, a validation check, analytics) works with stale data. Nothing is broken. In React, count is a constant that belongs to one render, and setCount asks React to render again with a new value; it never changes the variable you already have. This is the same in React 18 and React 19.
Quick fix checklist
- Need the new value in the same handler? Compute it first:
const next = count + 1; setCount(next); save(next);. - Updating the same state several times in one event? Use the updater form:
setCount(c => c + 1). - Updating inside
setInterval,setTimeoutor a subscription callback? Use the updater form, or the callback sees a stale snapshot. - Changed an object or array in place (
items.push(x); setItems(items))? Create a new one:setItems([...items, x]). - Need to run code after the screen updates? Only use
useEffectif you are syncing with something outside React, not for chaining logic. - Need the DOM updated synchronously (scroll to a new item)? Wrap the setter in
flushSync, sparingly.
Before you start
You should know how to declare state with useState and handle events in a function component. The model below is explained more fully in React state snapshots; this guide focuses on the bugs it causes and how to fix each one.
Why it happens
Each render calls your component function from the top. useState returns the value React has stored for that render, and you put it into a const. Every event handler created during that render closes over that constant:
function Counter() {
const [count, setCount] = useState(0); // 0 in this render, forever
function handleClick() {
setCount(count + 1); // "please render again with 1"
setCount(count + 1); // "please render again with 1" (count is still 0)
setCount(count + 1); // same again
}
return <button onClick={handleClick}>{count}</button>;
}One click shows 1, not 3. React queues the three requests and processes them together after the handler finishes; that is batching. Batching is why a handler that updates five pieces of state causes one render instead of five, and why you never see a half-updated UI.
Since React 18 (with createRoot), batching applies everywhere: inside promises, setTimeout, native event listeners and fetch callbacks. In React 17 and earlier, updates outside React’s own event handlers were applied synchronously, one render per call, which is why older Stack Overflow answers sometimes claim state “updates immediately in a timeout”. React 19 removed ReactDOM.render entirely, so the old behaviour is gone.
When the next render happens, useState returns the new value. So “not updating immediately” really means “not updating the variable in the current closure, ever”.
The updater function
To build on pending updates, pass a function. React queues it and calls it with the latest pending state:
function handleClick() {
setCount((c) => c + 1); // 0 -> 1
setCount((c) => c + 1); // 1 -> 2
setCount((c) => c + 1); // 2 -> 3
}Updaters must be pure because React may call them more than once (StrictMode does this deliberately in development). Updater purity explains why.
When the screen does not update at all
A different bug looks similar: the setter is called and nothing re-renders. React compares the new value with the old one using Object.is, and skips the render if they are the same. Mutating an array and passing it back gives React the same reference:
function handleAdd(item) {
items.push(item); // mutates the current state array
setItems(items); // same reference: React bails out, no render
}Worse, the mutation is still inside the array, so it appears on screen later, when some unrelated update triggers a render. Always create a new array or object: setItems([...items, item]) or setUser({ ...user, name }).
Step-by-step walkthrough
Step 1: Reproduce and confirm it is a snapshot issue
Log the value at the top of the component as well as in the handler:
console.log('render with count', count);If you see render with count 1 right after your handler logged 0, state is updating correctly and your code is reading an old snapshot. If no new “render” line appears, the setter received the same value (look for mutation) or was never called.
Step 2: Find code that reads state after setting it
Look for a setter followed by a read of the same state variable in the same function: validation, fetch bodies, localStorage.setItem, analytics calls, or another setter derived from it. Every one of those uses the old snapshot.
Step 3: Use the next value directly
Calculate the next value once, then use it for both the state update and everything else:
function handleQuantityChange(e) {
const nextQty = Number(e.target.value);
setQty(nextQty);
if (nextQty > stock) setError(`Only ${stock} left`);
trackEvent('quantity_changed', { qty: nextQty });
}This is the most common fix and needs no extra hooks.
Step 4: Fix stale closures in timers and subscriptions
Callbacks created once (in an effect with []) keep the snapshot from the render that created them:
useEffect(() => {
const id = setInterval(() => setSeconds(seconds + 1), 1000); // always 0 + 1
return () => clearInterval(id);
}, []);The counter sticks at 1. Use the updater form, setSeconds((s) => s + 1), so the callback never needs to read seconds. If a long-lived callback must read the latest value of something else, use useEffectEvent (stable since React 19.2) or keep the value in a ref that you update after render.
Step 5: Decide whether you need an effect
useEffect(() => { ... }, [count]) runs after a render where count changed. It is the right tool when the work is synchronising with something outside React because of the new state, regardless of what caused it:
useEffect(() => {
localStorage.setItem('cart', JSON.stringify(items));
}, [items]);It is the wrong tool for logic caused by a specific user action, such as “after the user submits, post the form”. Put that in the submit handler using the values you already have. Effects that set other state from state add an extra render and are a common source of Maximum update depth exceeded loops.
For the rare case where you must read the DOM right after an update, flushSync from react-dom applies the update synchronously:
flushSync(() => setMessages([...messages, newMessage]));
listRef.current.lastElementChild.scrollIntoView();The DOM is up to date after flushSync returns, but the messages variable in your handler is still the old snapshot.
Worked scenario
A shopping cart saves to the server when an item is added:
function Cart({ initialItems }) {
const [items, setItems] = useState(initialItems);
async function handleAdd(product) {
setItems([...items, product]);
await fetch('/api/cart', {
method: 'PUT',
body: JSON.stringify(items),
});
}
// ...
}Users report that the server cart is always one item behind. Diagnosis: items in handleAdd is the snapshot from the render that created the handler, so the request sends the list without the product just added. Reproducing it with one starting item, state became two items while the saved body still had one.
async function handleAdd(product) {
const nextItems = [...items, product];
setItems(nextItems);
await fetch('/api/cart', {
method: 'PUT',
body: JSON.stringify(nextItems),
});
}nextItems is used for both the state and the request, so they cannot disagree. If clicks can arrive faster than renders (a double-click), use setItems((prev) => [...prev, product]) for the state and derive the saved list from the server response, or disable the button while saving.
Common mistake
The tempting fix is adding await in front of the setter, or wrapping the rest of the handler in setTimeout, hoping state will be updated afterwards. Setters return undefined, not a promise, so await setCount(1) only waits a microtask, and count is still the same constant: a closure cannot see a later render’s variables no matter how long it waits.
Another is moving every follow-up into useEffect with the state as a dependency. It appears to work, but the effect also fires when the state changes for other reasons (loading saved data, a reset button), and the request is sent one render late. Keep event-specific logic in the event handler.
Class component users sometimes read this.state right after this.setState. It has the same delay; use the second argument (this.setState(next, () => ...)) or, better, compute the value first.
Verify the behavior
Test the observable result, not the internal timing:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('saves the cart including the added item', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}'));
render(<Cart initialItems={[{ id: 'kb' }]} />); // renders an 'Add mouse' button
await userEvent.click(screen.getByRole('button', { name: 'Add mouse' }));
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
expect(body.map((item) => item.id)).toEqual(['kb', 'mouse']);
});With the stale version, the body is ['kb'] and the test fails. For counters, click three times in a test and assert the text, or call three updaters in one handler and expect 3.
Interview exercise
What does this log, and what does the button show after one click? How would you make it show 5?
const [n, setN] = useState(0);
function handleClick() {
setN(n + 5);
setN((prev) => prev + 1);
console.log(n);
}Answer and reasoning
It logs 0, because n is the snapshot from the current render and setters never change it. The button then shows 6. React processes the queue in order: setN(n + 5) is a replacement, so the pending value becomes 0 + 5 = 5; the updater receives that pending 5 and returns 6. Mixing a replacement and an updater is legal, and the order matters. To show 5, remove the second call or make both explicit: setN((prev) => prev + 5). To log the value that will be rendered, compute it first: const next = n + 5; setN(next); console.log(next);. Mentioning that batching means one render for both calls, and that React 18 extended batching to timeouts and promises, makes the answer complete.
Continue learning
Practise with the React interview questions and the React MCQs. React state snapshots explains the mental model, updater purity covers functional updates, and useEffect pitfalls helps decide when an effect is justified. If setting state in the wrong place crashes your app, see Too many re-renders. On react.dev, read State as a Snapshot, Queueing a Series of State Updates and the useState reference.