Pick a depth. Each prompt opens in your AI pre-loaded with the lesson. Click a row to preview the prompt.
setState does not update state synchronously — it enqueues an update and schedules a re-render. Since React 18, all updates are batched regardless of where they originate (event handlers, timeouts, promises), so three setState calls in one tick produce one re-render. The stale closure trap emerges when an async callback closes over an old state value: the callback sees state as it was when the function was created, not when it runs. Functional updates (prev => prev + 1) solve this by receiving the latest queued state rather than relying on the captured closure.
Three setState calls in one synchronous block produce one re-render. An async callback that closes over count sees its value at creation time — the functional updater form bypasses that.
console.log('render') at the component's top level. Click the button and count how many times it logs. With three setState calls you should see exactly one 'render' — that's batching in action.flushSync (import from react-dom). Now each setState call triggers its own re-render. Count the renders again — you should see three. This shows what batching prevents.c => c + 1 with count + 1 in startAsyncFixed. Open two tabs, click rapidly in both, and observe the count diverge from expected — this is the shared mutable state race condition that functional updates prevent.import { useState } from 'react';
export function BatchDemo() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
function handleClick() {
// React 18: these three updates are batched → ONE re-render
setCount(c => c + 1);
setCount(c => c + 1);
setText(t => t + '.');
console.log(count); // still shows OLD value — state is read from closure
}
return <button onClick={handleClick}>{text} {count}</button>;
}
export function StaleClosureDemo() {
const [count, setCount] = useState(0);
function startAsync() {
// BAD: setTimeout closes over count at the moment startAsync is called
setTimeout(() => {
setCount(count + 1); // stale! always increments from captured value
}, 1000);
}
function startAsyncFixed() {
// GOOD: functional update receives the latest queued state
setTimeout(() => {
setCount(prev => prev + 1); // always correct
}, 1000);
}
return (
<div>
<p>Count: {count}</p>
<button onClick={startAsync}>Stale +1 (bad)</button>
<button onClick={startAsyncFixed}>Safe +1 (good)</button>
</div>
);
}