Why Three Consecutive setStates Only Increment Once — and How to Fix It
The closure-and-batching trap is one of the most common React bugs and a staple of frontend interviews. Getting it wrong produces counters that skip, forms that submit stale data, and effects that fire with outdated dependencies — all of which are preventable with a one-line change to a functional updater.
Calling setCount(count + 1) three times in a row inside a single event handler increments the counter by one, not three. The reason is a combination of React's batching — which merges multiple queued updates into a single render — and JavaScript closure snapshots, where each call reads the same stale value captured at render time. The solution is to pass a callback to the setter: setCount(prev => prev + 1). This chains updates sequentially, with each callback receiving the result of the previous one, while still triggering only one re-render.
Beyond the closure trap, the same mechanism explains why logging state immediately after calling a setter prints the old value. React pushes updates into an internal queue and processes them after the current synchronous code finishes. Understanding this flow eliminates a whole category of timing bugs.
Two other patterns round out the picture: derived state should not be stored in useState when it can be computed from existing state or props, and expensive initial values should be wrapped in a lazy initializer function so they run only on mount, not on every render.
The closure-snapshot behavior is not a React quirk but a direct consequence of JavaScript's lexical scoping: each render is a fresh function call with its own constant bindings. Developers who treat components as persistent objects rather than repeated function invocations will keep hitting this wall.
React's batching is often framed as a performance optimization, but it also acts as a correctness guardrail. Without it, interleaved synchronous and asynchronous updates would produce even more confusing intermediate states.
The advice to avoid storing derived state in useState is a specific case of the single-source-of-truth principle, but it's routinely violated because useEffect feels like the obvious place to synchronize data. That instinct leads to extra renders and desync bugs that useMemo avoids entirely.