Open this lesson in your favourite AI. It'll walk you through the why, explain the demo, and quiz you on the try-it list.
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.
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>
);
}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.Use these three in order. Each builds on the one before.
In one paragraph, explain the difference between `setCount(count + 1)` and `setCount(prev => prev + 1)`. When does it matter which you use?
Walk me through React 18's automatic batching. How does React accumulate updates within a single event loop tick and what triggers the flush? What changed from React 17 where only React event handlers were batched?
The stale closure problem affects not just useState but any value captured by a callback. Describe three strategies for dealing with stale closures in React (functional updater, useRef, useEffect dependency array) and the trade-offs of each.