跪拜 Guibai
← All articles
JavaScript

Why Three Consecutive setStates Only Increment Once — and How to Fix It

By mONESY ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

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.

Summary

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.

Takeaways
Three consecutive setCount(count + 1) calls inside the same handler increment count by 1, not 3, because each call reads the same render-snapshot value.
React batches multiple state updates from a single event loop turn into one re-render; only the last update in a sequence of value-based setters takes effect.
Passing a callback — setCount(prev => prev + 1) — chains updates so each call receives the previous call's result, correctly accumulating changes.
Logging state immediately after calling a setter prints the old value because React processes the update queue asynchronously after synchronous code finishes.
Derived values like filtered lists should be computed during render, not stored in separate useState variables, to avoid synchronization bugs and redundant state.
Wrapping an expensive initial-value computation in a function — useState(() => heavyWork()) — runs it only on mount; useState(heavyWork()) re-executes it on every render.
Conclusions

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.

Concepts & terms
Closure snapshot
Each render of a React function component creates a new function scope with its own constant bindings for state and props. Code inside event handlers and effects captures the values from the render that created them, not future values.
Functional updater
A callback passed to a state setter (e.g., setCount(prev => prev + 1)) that receives the most recent queued state value rather than the value from the render closure. It enables sequential state updates that depend on the previous result.
Batching
React's behavior of collecting multiple state updates triggered in the same synchronous execution context and applying them in a single re-render. In React 18, batching extends to timeouts, promises, and native event handlers.
Lazy initializer
A function passed as the initial argument to useState that runs only during the component's first mount. Subsequent re-renders skip the function and reuse the stored initial value, avoiding repeated expensive computations.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗