React State Is One Click Behind - How Batched Updates Work
6 min read
“The button says +3. Click it once. The score goes up by 1. Click it again - up by 1 again. Three setState calls ran, three times each click - and somehow only one of them counted.”
The button is labeled "Triple Score (+3)." Click it once. The score goes
from 0 to 1. Click it again - 1 to 2. Each click adds exactly 1, not 3,
even though the code calls setScore three separate times per click.
Nothing is wrong with the math inside each call. The problem is what
"current value" means when you ask three times in a row, in the same
handler, before React has done anything with the first answer.
When handleTriple runs, score is a regular JavaScript variable holding
whatever value it was at the start of this render - say, 0. It does not
update mid-function. It's not a live connection to "the real current
score"; it's a snapshot, frozen for the entire duration of this render and
this handler.
So all three lines read the samescore, which is 0. All three compute
0 + 1 = 1. All three call setScore(1).
React doesn't apply these one at a time, re-rendering between each. Inside
a single event handler, React batches state updates - it collects all of
them and processes them together after the handler finishes. Three calls to
setScore(1) are, from React's perspective, three requests that all say
"the new score should be 1." The net result is score becomes 1. Once.
Symptom
Click "Triple Score (+3)" once: score goes from 0 to 1, not 0 to 3. Click
it again: 1 to 2, not 1 to 6. Each click reliably adds exactly 1, as if the
other two setScore calls weren't there at all.
function handleTriple() { setScore((s) => s + 1); setScore((s) => s + 1); setScore((s) => s + 1);}
Same three calls, same triple repetition - but now each one passes a
function instead of a value. A functional updater doesn't read score
from the render's snapshot. Instead, React queues it, and when it's time to
apply the queue, each function receives whatever the result of the previous
one was.
Starting from score = 0: the first (s) => s + 1 receives 0, returns
1. The second receives 1 (the result of the first), returns 2. The
third receives 2, returns 3. React applies the final result, 3, and
score goes from 0 to 3 in one click - actually tripled, this time.
This is the same fix as "Counter Never Updates" and "Run Counter Never
Stops" elsewhere on this site, applied for a different reason: there, the
issue was Object.is rejecting a value that hadn't changed. Here, the issue
is multiple calls all reading the same starting point. Both are solved by
not reading the snapshot at all - asking React for "whatever the value is by
the time this update applies" instead.
The single-variable version above is the cleanest demonstration, but the
same snapshot problem shows up just as often across two different pieces
of state in the same handler.
Click "Add Item" once. Items becomes 1 - correct. Total stays at $0 -
should be $10. Click again. Items becomes 2. Total becomes $10 - the
amount it should have been after the first click.
setCount(count + 1) and setTotal(total + count * 10) both run with the
same snapshot: on the first click, count is 0 and total is 0,
for both lines, regardless of the fact that the first line is "supposed
to" change count before the second line reads it. setTotal(0 + 0 * 10)
is setTotal(0). Total doesn't move. One click later, the snapshot is
count = 1, total = 0 (the values after the first click finally applied),
so setTotal(0 + 1 * 10) = setTotal(10) - the $10 that should have appeared
on click one shows up on click two instead.
function addItem() { setCount((c) => c + 1); setTotal((t) => t + 10);}
The fix removes the cross-dependency entirely. setTotal no longer reads
count at all - it just adds a flat 10 to whatever total currently is,
via its own functional updater. Both updates are now self-contained: each
one only depends on its own previous value, which React always has correct
access to regardless of batching or read-order.
The Sensei's Hint
The two-variable version is easy to miss because each individual line
looks fine on its own - setCount(count + 1) is a completely normal
increment. The bug only appears when a second state update in the same
handler tries to read a value that the first update was supposed to have
already changed.
Inside one event handler, every plain read of a state variable - count,
score, total - returns the same value, no matter how many times you read
it or how many setState calls came before. State updates don't take effect
until React processes the batch after the handler returns.
Constraints
If a setState call's new value is computed using another piece of
state that this same handler also updates - or using the same state
variable more than once - that's the signal to switch to functional
updaters: setX((x) => ...). The function form is the only way to read
"what the value will be after the other updates in this batch," rather than
"what the value was when this render started."
"Triple Score Always Adds One" is the cleanest version - one variable,
three identical calls, easiest to trace by hand. Work out on paper what
score, s, and the queued updates look like at each step before checking
the fix. "Total Price Is Always One Click Behind" adds a second variable and
a cross-dependency between them - the lag is more visible here (you can
watch the total trail the count by exactly one click), and it's a good test
of whether the "snapshot, not a live value" model has really clicked.