JavaScript Closure and React Batching: Why State Updates Feel Weird

Hoang Vu,6 min read

March 30, 2026

Many state update bugs are not about React APIs themselves. They come from closures capturing old values, then interacting with React's batching behavior.

If you think of setState as an immediate mutation, these cases become confusing very quickly.


1. Closure in debugging terms

A closure is when a function retains access to variables from its outer scope.

function createCounter() {
  let value = 0;
 
  return () => {
    value += 1;
    return value;
  };
}
 
const next = createCounter();
console.log(next()); // 1
console.log(next()); // 2

The key React detail is: every render is a new snapshot of data.

That means:

  1. Render A creates handleClickA, which captures state from render A.
  2. Render B creates handleClickB, which captures state from render B.
  3. If a callback runs later (timeout, promise), it still reads the snapshot captured when it was created.

So closure is not a bug. It is just language behavior. Bugs happen when we expect callbacks to always read the latest state, while they are actually reading a previously captured value.

A practical debugging model:

  1. render creates a snapshot.
  2. handler/callback reads only its own snapshot.
  3. The later a callback runs, the more likely it is stale versus current UI state.

CLOSURE SNAPSHOT PER RENDER

Step 1/3

Render #1

count = 0

handler: handleClick@R1

Render #2

count = 1

handler: handleClick@R2

Render #3

count = 2

handler: handleClick@R3

Active closure snapshot

const [count, setCount] = useState( 0);

const handleClick@R1 = () => {

// uses captured count from this render

setCount(count + 1);

};

A new handler is created and captures count = 0.

React re-renderNew click uses latest handler

2. How React batching changes outcomes

React often groups multiple setState calls into one batch to reduce re-renders.

Conceptually, setState does not mutate state immediately in place. It enqueues an update, then React flushes that queue.

There are two update shapes you should separate clearly:

2.1 Direct value update

setCount(count + 1)

React receives a precomputed value based on the current closure.

If you call this multiple times in one batch, those calls often use the same captured count. The later write can override earlier writes.

2.2 Functional updater

setCount((prev) => prev + 1)

React receives a transition function. During flush, React executes each updater with the latest prev value in queue order.

That is why functional updaters are safer for chained updates and async callbacks.

2.3 What is flush, exactly?

"Flush" is the moment React processes enqueued updates to compute the next state and commit a render.

You can think of it as a pipeline:

  1. Collect phase: Event/callback runs, multiple setState calls are enqueued.
  2. Flush phase: React walks the queue in enqueue order (FIFO logic).
  3. Commit phase: React commits the final computed state to the UI.

Important details:

  1. Flush does not mean "each setState renders immediately".
  2. In one batch, React usually waits until the current event/callback completes before flushing.
  3. Each queued item is applied to the latest intermediate state, then passed to the next item.

Example with mixed direct values and updater callbacks:

setCount(5);                // direct value
setCount((prev) => prev+1); // updater
setCount(7);                // direct value

If base state is 0, flush order is:

  1. apply 5 -> intermediate state 5
  2. apply updater -> intermediate state 6
  3. apply 7 -> intermediate state 7

Final state is 7.

So when we say "batching determines flush timing and order", we mean:

  1. React decides when queue processing starts.
  2. Queue order decides which update runs first/next.
  3. Together, they directly shape the final state.

2.4 How closure affects batching

The common failure pattern is:

  1. A callback captures stale closure data.
  2. It enqueues direct-value updates.
  3. React batches and flushes later.
  4. Final state differs from the intuitive "step-by-step arithmetic" expectation.

In short:

  1. Closure determines update input.
  2. Batching determines when/how queue is flushed.
  3. Their combination determines the final state.

If the input is stale, batching cannot magically fix it. That is why switching from direct values to updater functions is the reliable pattern whenever next state depends on previous state.


3. Example 1: one click, two opposite updates

const [count, setCount] = useState(0);
 
const handleClickButton = () => {
  setCount(count - 1);
  setCount(count + 1);
};

After one click, what is count?

Final result: 1.

Why:

  1. In this render, closure has count = 0.
  2. First call computes -1.
  3. Second call computes 1.
  4. Both are batched in one event.
  5. Last queued value wins, so final state is 1.

If you expected 0, that expectation assumes sequential mutation, but here updates are queued and computed from the same captured value.

REACT BATCHING: SINGLE CLICK CASE

Step 1/4
Code mapping
const [count, setCount] = useState(0);
const handleClickButton = () => {
setCount(count - 1);
setCount(count + 1);
};

Step 1: Handler runs with closure count = 0.

Update queue

(flushed)

State

count = 0

Expected after click: count = 1

Hands-on with full component code

INTERACTIVE DEMO: SINGLE CLICK BATCHING

Full component code

function SingleClickDemo() {
  const [count, setCount] = useState(0);

  const handleClickButton = () => {
    setCount(count - 1);
    setCount(count + 1);
  };

  return (
    <div>
      <p>count: {count}</p>
      <button onClick={handleClickButton}>Click 1 lần</button>
    </div>
  );
}

Live playground

count: 0

count cuối cùng sau 1 click: 1

Run logs

  • (chua co log)

4. Example 2: click 3 times quickly within 3 seconds

const [count, setCount] = useState(0);
 
const handleClickButton = () => {
  setCount(count - 1);
 
  setTimeout(() => {
    setCount(count + 1);
  }, 3000);
};

Assume you click 3 times quickly within 3 seconds.

Timeline

At click time:

  1. Click 1 closure sees count = 0 -> sets -1
  2. Click 2 closure sees count = -1 -> sets -2
  3. Click 3 closure sees count = -2 -> sets -3

After 3 seconds, the 3 timeouts run:

  1. Timeout from click 1 uses closure count = 0 -> sets 1
  2. Timeout from click 2 uses closure count = -1 -> sets 0
  3. Timeout from click 3 uses closure count = -2 -> sets -1

Final result after all timeouts: -1.

This is rarely what people expect, because timeout callbacks are still using captured state snapshots.

3 CLICKS + 3S TIMEOUT: DIRECT VS UPDATER

Step 1/6
Timeline step 1/6

Direct value: Click 1 immediate: 0 -> -1

Functional updater: Click 1 immediate: 0 -> -1

direct count: -1

updater count: -1

Stale closure path (direct value)
const handleClickButton = () => {
setCount(count - 1);
setTimeout(() => setCount(count + 1), 3000);
};

Final after all timeouts: count = -1

Correct path (functional updater)
const handleClickButton = () => {
setCount((prev) => prev - 1);
setTimeout(() => setCount((prev) => prev + 1), 3000);
};

Final after all timeouts: count = 0

Hands-on with full component code

INTERACTIVE DEMO: 3 CLICKS + 3S TIMEOUT

Full component code (stale closure)

function StaleTimeoutDemo() {
  const [count, setCount] = useState(0);

  const handleClickButton = () => {
    setCount(count - 1);
    setTimeout(() => {
      setCount(count + 1);
    }, 3000);
  };

  return <button onClick={handleClickButton}>Click</button>;
}

Full component code (functional updater)

function UpdaterTimeoutDemo() {
  const [count, setCount] = useState(0);

  const handleClickButton = () => {
    setCount((prev) => prev - 1);
    setTimeout(() => {
      setCount((prev) => prev + 1);
    }, 3000);
  };

  return <button onClick={handleClickButton}>Click</button>;
}

Live playground

Stale closure path

count: 0

Updater path

count: 0

Kỳ vọng khi simulate 3 click: stale thường về -1, updater về 0.

Run logs

  • (chua co log)

5. Correct pattern: functional updater

Use updater callbacks so each update is calculated from the latest state at processing time:

const [count, setCount] = useState(0);
 
const handleClickButton = () => {
  setCount((prev) => prev - 1);
 
  setTimeout(() => {
    setCount((prev) => prev + 1);
  }, 3000);
};

Benefits:

  1. No dependency on stale closure values.
  2. Each update uses latest queued state (prev).
  3. Predictable behavior across events and async callbacks.

What does the setState callback actually do?

When you write setCount((prev) => prev + 1), that callback does not run immediately on that line. React enqueues it as an update.

When React flushes the queue, each updater is executed in order with the latest intermediate state:

  1. Start from the queue base state.
  2. Run updater #1 with current prev -> produce next state.
  3. Run updater #2 with the newly produced state.
  4. Continue until queue is exhausted, then commit render.

Example:

setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);

If base state is 0, flush sequence is:

  1. updater 1: prev=0 -> 1
  2. updater 2: prev=1 -> 2
  3. updater 3: prev=2 -> 3

Final state is 3.

Compare that with direct values:

setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

If closure captured count=0, all three enqueue the same value 1, so final state is usually just 1.

Summary:

  1. Updater callbacks are state transition functions, not precomputed values.
  2. prev is the freshest queue state at execution time, not the old closure variable.
  3. That is why updater callbacks are the default safe choice whenever next state depends on previous state.

6. Quick comparison: direct value vs updater

Direct value (stale-prone)

setCount(count + 1);
setCount(count + 1);

With count = 0, the result is typically 1, not 2.

Functional updater (safe)

setCount((prev) => prev + 1);
setCount((prev) => prev + 1);

Result is 2, because each update receives the latest pending state.


7. Closure-safe React checklist

  1. If next state depends on previous state, prefer functional updater.
  2. Be careful with setTimeout, Promise.then, and external listeners.
  3. During debugging, ask: "Which render created this function?"
  4. Keep state transition logic small and explicit for easier testing.

Wrap-up

Closures are not wrong. Batching is not wrong. The bug appears when we expect synchronous mutation while the runtime follows queued async updates.

If you remember one rule, remember this:

If next state depends on previous state, use setState((prev) => ...).

2026 © @hoag/blog.