Back to the Library

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.”

In This Post

What `score` Means Inside an Event HandlerThe Fix: Stop Reading the SnapshotThe Two-Variable VersionThe General RulePractice These Patterns

Practice This Pattern

White Belt

Triple Score Always Adds One

A Triple Score button calls setScore three times in one click - each call reads the same snapshot of the current score, so all three collapse into a single +1.

+10 KI
Enter the Dojo
Blue Belt

Total Price Is Always One Click Behind

A cart's item count and total price both update on Add Item - but the total is calculated from count's value before this click, so it's always exactly one click late.

+25 KI
Enter the Dojo
BugDojo
BlogFAQ

© 2026. Carved in code.

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.

What score Means Inside an Event Handler

export default function GameScore() {
  const [score, setScore] = useState(0);
 
  function handleTriple() {
    setScore(score + 1);
    setScore(score + 1);
    setScore(score + 1);
  }
 
  return (
    <div>
      <p>Score: {score}</p>
      <button onClick={handleTriple}>Triple Score (+3)</button>
    </div>
  );
}

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 same score, 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.

The Fix: Stop Reading the Snapshot

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 Two-Variable Version

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.

export default function Cart() {
  const [count, setCount] = useState(0);
  const [total, setTotal] = useState(0);
 
  function addItem() {
    setCount(count + 1);
    setTotal(total + count * 10);
  }
 
  return (
    <div>
      <p>Items: {count}</p>
      <p>Total: ${total}</p>
      <button onClick={addItem}>Add Item ($10)</button>
    </div>
  );
}

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.

The General Rule

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."

Practice These Patterns

"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.