I lost an evening once to four lines of React. A counter that should have incremented by three when a user clicked a button incremented by one. The code looked fine. The fix was a one-character change. The reason it failed had nothing to do with React being weird and everything to do with how closures work and how React batches updates. I now use this exact bug as the test for whether someone has internalized React's mental model or is just pattern-matching.
My stance, and the spine of this article: most teams use the imperative setCount(count + 1) form by default and reach for the functional setCount((c) => c + 1) form only when they remember. That is backwards. The functional form should be the default, and the imperative form should be reserved for the small set of cases where you genuinely want to overwrite, not increment. Once you make that flip, an entire class of bugs disappears from your codebase.
The bug that took me an evening
The component was a tip calculator. Three buttons added 5%, 10%, and 15% to the running tip percentage. The expectation: clicking 5%, then 10%, then 15% in rapid succession leaves the tip at 30%. The code:
With one click at a time it works. With three clicks fast enough that they fell into the same React update cycle, plus a wrapper that fired all three programmatically in a test, the result was 15, not 30. Two of the three calls were silently ignored.
The explanation involves three things working together: closures, batching, and the fact that tip inside addTip is not a live reference, it is a value frozen at render time.
What batching actually does in React 18
Before React 18, React batched multiple state updates only inside its own event handlers like onClick. React 18 added automatic batching, which extends that same batching to promise resolutions, setTimeout and setInterval callbacks, native event handlers, and code after an await. So today, multiple state updates in any of those contexts collapse into a single re-render. The batching is the optimization that lets setA(1); setB(2); setC(3) produce one render instead of three. That part is good and saves a lot of work.
The consequence I keep tripping on: between the first setState call and the eventual re-render, the component function has not run again. State variables in the closure are still the values they had at the start of the batch. So this:
Three calls. All three queue an update that says "set count to 0 + 1 = 1". After the batch, count is 1, not 3. The variable count in the function body never changed; it is the value that was passed into this render.
This is the same trap as the tip calculator. The three button clicks all read tip as 0 (the value at the start of the batch), each computed 0 + amount, and the last write won. 0 + 15 = 15.
Where the stale closure comes from
The word "closure" gets thrown around without being explained, and that is part of why this bug feels mysterious. A closure is a function bundled with the variables it can see at the moment it was created. In React, every render creates a new set of closures over the state values that render received.
Render 1 creates handleClick where count = 0. Render 2 creates a new handleClick where count = 1. Render 3 creates another, where count = 2. They are different functions even though the source code is identical. The button's onClick prop holds whichever handleClick was current the last time the component rendered.
When three rapid clicks all hit the same render's handleClick, they all see the same captured count. There is no "latest value" inside the closure. The closure is a snapshot.
This is why the imperative form is fragile. setCount(count + 1) is doing arithmetic on a snapshot. If the snapshot is stale, the arithmetic is wrong.
The functional updater is the fix
React's setState (and useState's setter) accepts two forms. Imperative: setState(newValue). Functional: setState((previous) => newValue). The functional form is given the current pending value of state, not whatever the closure captured at render time.
Now three rapid calls do the right thing. React applies the updaters in order: starting from 0, the first updater returns 5; the second receives 5 and returns 15; the third receives 15 and returns 30. The final state is 30, the rendered output is 30, and the tip calculator works as expected.
The rule I follow now: any setState call whose new value depends on the previous value uses the functional form. Counters, toggles, list appends, list removes, object spreads. The imperative form is only for setting a value that does not depend on the previous one.
This is not just defensive coding. It is the form that correctly expresses your intent. "Add this item to whatever the list currently is" is what setItems((prev) => [...prev, newItem]) says. setItems([...items, newItem]) says "add this item to the list as it was when this function was created", which is a subtly different thing and the source of every list-append bug I have ever seen.
When the bug becomes invisible: async boundaries
The tip calculator bug is at least obvious in production once you notice it. The version that ate my evening was harder. It involved an async function:
Looks fine, ships, works in manual testing. The bug: a user clicks "add" three times in two seconds. Three async calls go out. They resolve in some order, not necessarily the click order. Each one reads items from the closure at the time addItem was created, not the latest state. The first to resolve overwrites with [...originalItems, product1]. The second overwrites with [...originalItems, product2], throwing away product1. The third overwrites with [...originalItems, product3], throwing away product1 and product2.
User added three items. Cart shows one. The functional form fixes this too:
Now each callback receives the actual current state, not a snapshot from when the click happened. The three resolutions append in resolution order, not click order, but all three end up in the cart.
If click order matters (it usually does for UX), that is a different problem and the answer is queueing the requests, not setState semantics. But at least no items get silently dropped.
The broader point: any time setState happens after an await, the closure has been stale for as long as the await took. Network is slow, awaits are long, the closure is very stale. The functional form is not optional in async code, it is the only correct shape.
A subtle case: useReducer is the same trap, deeper
useReducer does not save you from this. The reducer function is itself a closure created during render, so any value it reaches for from outside its arguments is a snapshot:
If step changed between when the reducer was created and when an action was dispatched, the reducer uses the old step. The fix is to put the changing value into the action: dispatch({ type: 'increment', step }). Then the reducer reads it from action, which is fresh, instead of from its closure, which is not.
The pattern generalizes: anywhere a function might run later (event handler, setTimeout, setInterval, async resolution, useEffect setup), assume the closure is stale and pass current values in explicitly, either via the functional updater form or via arguments.
Run this on your codebase tomorrow
Grep for setState\(.*State.*\) patterns where the inside references the same state variable. In a TypeScript project, ESLint's react-hooks/exhaustive-deps and the React compiler (when it lands) will catch many of these, but a manual sweep finds the ones the lints miss. Replace each with the functional updater form, run your tests, ship it. The change is mechanical and the cleanup is real.
The deeper habit takes longer. Train yourself to write setX((prev) => ...) first and reach back to the imperative form only when you have decided the new value genuinely does not depend on the old. After a couple of weeks the new shape feels natural, and the class of stale-closure bugs that used to ship from your team stops shipping. That is the entire payoff: a category of bugs disappears, and the React code reads more honestly about what it is actually doing.
