⚛ The Course  /  Module 5 · A Full Update, End to End · 5.2
Module 5 · A Full Update, End to End

5.2 Batching: many setStates, one render

You call setState three times in a row inside one click handler. Does React render three times — once per call — or just once? This lesson answers that, and shows the small, surprising trick that makes the answer "once."

The thing batching is solving

Re-rendering is the expensive part. If React rebuilt the screen after every individual setState, a handler with three updates would do three full render-and-commit cycles, and the user might even glimpse half-updated screens in between.

So React wants the opposite: no matter how many setState calls land in one synchronous block, collapse them into one render. That collapsing is called batching.

The real code

function handleClick() {
  // ========== ALL UPDATES BATCHED AUTOMATICALLY ==========
  setCount(c => c + 1);    // Update 1
  setFlag(f => !f);        // Update 2
  setData(d => [...d]);    // Update 3

  // React 18: Only ONE render!
  // All updates processed together with SyncLane priority

  console.log(count);  // Still old value! (batched)
}

// React 17: Only batched inside React event handlers
// React 18: Batched everywhere (timeouts, promises, native events)

Notice the console.log(count) at the bottom: it still prints the old value. That is the giveaway that no render has happened yet — the three updates are sitting in a queue, waiting. The new count only exists after React replays that queue during the (single) render to come.

The one idea: defer the render to a microtask

Here is the surprise. Batching is not a flag that says "I'm inside an event, hold off rendering." In React 18 it is something simpler: the very first setState in a tick does not render — it schedules a microtask to do the render later, once the current call stack empties.

A quick reminder of the two queues the browser keeps. A microtask runs right after the current JavaScript finishes, before the browser can paint. Your synchronous handler runs to completion first; only then does the queued microtask fire. That gap is exactly where batching lives.

handler runs:  setState  setState  setState   ← all enqueue, none render
               │
       stack empties
               │
       ▶ microtask fires → ONE render → ONE commit

Because the render is deferred until after the handler returns, every setState in that handler has already dropped its update into the queue by the time the render runs. One render sees all three updates.

But what stops three microtasks?

If each setState scheduled its own microtask, you would still get three renders — just slightly later. So there must be a second piece: a guard that schedules the microtask only once, and lets every later call reuse it.

That guard lives in ensureRootIsScheduled (the function 5.1 ended on). When a render is requested, React computes the priority of the work — for a click that is SyncLane — and compares it to the priority of the task already scheduled on the root. If they match, a task is already on the way, so React returns early and schedules nothing new.

setState #1 → callbackPriority is empty → schedule the microtask, remember "SyncLane"
setState #2 → callbackPriority === SyncLane?  YES → return early (reuse it)
setState #3 → callbackPriority === SyncLane?  YES → return early (reuse it)

So the first call sets things up; the rest find the work already scheduled and bow out. One microtask, one render — for any number of same-priority updates in the tick.

The two mechanisms, named

Put together, batching in React 18 is exactly two cooperating pieces:

  • Microtask deferral — the render is pushed to a microtask, so it runs after the synchronous burst of setState calls, by which point every update is already enqueued.
  • Priority dedupif (root.callbackPriority === newCallbackPriority) return; means only the first call actually schedules; the rest reuse that one scheduled microtask.

The real code

// ── MECHANISM #1: Microtask DEDUPLICATION (in ensureRootIsScheduled) ──
// Each setState reaches here. If a task at the same priority is already
// pending, reuse it instead of scheduling render again → this is batching.
if (root.callbackPriority === newCallbackPriority) {
  return;  // ← reuse existing scheduled microtask
}
scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
scheduleMicrotask(flushSyncCallbacks);  // ← DEFER the flush, don't render now
root.callbackPriority = newCallbackPriority;

// ── MECHANISM #2: Guard inside the MICROTASK ──
scheduleMicrotask(function () {
  // Only flush if we're not already mid-render/commit (re-entrancy guard).
  if ((executionContext & (RenderContext | CommitContext)) === NoContext) {
    flushSyncCallbacks();  // ← Renders ALL updates batched in this tick
  }
});

See it in ensureRootIsScheduled

Here is the deduplication guard and the microtask deferral in their real home. The SyncLane branch is the path a click takes; the else branch (a Scheduler macrotask) is for concurrent work like transitions — covered in Module 4.

The real code

  // ── ensureRootIsScheduled ───────────────────────────────────────────
  function ensureRootIsScheduled(root, currentTime) {
    const nextLanes = getNextLanes(root, ...);       // Returns: SyncLane (0b1)
    const newCallbackPriority = getHighestPriorityLane(nextLanes);

    // Reuse the already-scheduled task if priority is unchanged → batching!
    if (root.callbackPriority === newCallbackPriority) {
      return;  // ← A microtask is already scheduled; don't schedule another.
    }

    if (newCallbackPriority === SyncLane) {
      scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));

      // Defer the flush to a MICROTASK (this is what enables batching):
      scheduleMicrotask(() => {
        if ((executionContext & (RenderContext | CommitContext)) === NoContext) {
          flushSyncCallbacks();  // ← Renders all batched updates together
        }
      });
    } else {
      const schedulerPriorityLevel = lanesToSchedulerPriority(nextLanes);
      scheduleCallback(schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root));
    }
    root.callbackPriority = newCallbackPriority;
  }

Trace it for a click handler with three setState calls. Each one runs the full path — dispatchSetState → enqueueConcurrentHookUpdate → scheduleUpdateOnFiber → ensureRootIsScheduled — but only the first survives past the dedup guard.

Every setState runs dispatchSetState → enqueueConcurrentHookUpdate
  → scheduleUpdateOnFiber → ensureRootIsScheduled.

setState #1:
  enqueueConcurrentHookUpdate(update1)          ← update object registered
  ensureRootIsScheduled:
    newCallbackPriority = SyncLane
    root.callbackPriority = NoLane (0)  →  NOT equal  →  proceed
    scheduleSyncCallback(performSyncWorkOnRoot)  ← pushes onto syncQueue
    scheduleMicrotask(flushSyncCallbacks)        ← defers the flush
    root.callbackPriority = SyncLane

setState #2:
  enqueueConcurrentHookUpdate(update2)          ← update registered
  ensureRootIsScheduled:
    newCallbackPriority = SyncLane
    root.callbackPriority = SyncLane  →  EQUAL  →  return;   ← skips scheduleSyncCallback + microtask

setState #3:
  enqueueConcurrentHookUpdate(update3)          ← update registered
  ensureRootIsScheduled:  EQUAL → return;        ← skips again

── handler returns, call stack empties ──

microtask runs → flushSyncCallbacks → performSyncWorkOnRoot
  → ONE render, processes update1 + update2 + update3 → ONE commit

Read the bottom two lines slowly: all three update objects are still in the queue. When the single render runs, performSyncWorkOnRoot replays update1, update2, and update3 in order — and commits the result once.

Show the full timeline (with timestamps)
T=0ms:    dispatchEvent() called
          ├─ executionContext = NoContext (0)  // no EventContext in React 18
          └─ Call handleClick()

T=0.1ms:  setCount() called
          ├─ dispatchSetState() → scheduleUpdateOnFiber() → ensureRootIsScheduled()
          ├─ callbackPriority empty → scheduleSyncCallback()
          ├─ scheduleMicrotask(flushSyncCallbacks)  // ← defer flush
          ├─ root.callbackPriority = SyncLane
          └─ Return without rendering

T=0.2ms:  setFlag() called
          ├─ dispatchSetState() → ensureRootIsScheduled()
          ├─ CHECK: root.callbackPriority === SyncLane?
          ├─ → YES! → return early (reuse existing microtask)
          └─ Return without rendering

T=0.3ms:  setData() called
          ├─ dispatchSetState() → ensureRootIsScheduled()
          ├─ CHECK: root.callbackPriority === SyncLane?
          ├─ → YES! → return early (reuse existing microtask)
          └─ Return without rendering

T=0.4ms:  handleClick() returns; dispatchEvent() returns
          └─ Synchronous call stack empties

T=0.5ms:  [MICROTASK QUEUE] flushSyncCallbacks runs
          ├─ CHECK: (executionContext & (RenderContext|CommitContext)) === NoContext?
          ├─ → YES!
          └─ flushSyncCallbacks()  // ← NOW render all 3 updates!

T=1ms:    Render phase (all 3 updates processed together)
T=3ms:    Commit phase (DOM updated once)

What "automatic" means: await splits the batch

React 17 only batched updates that ran inside a React event handler. Updates in a setTimeout, a promise .then, or after an await each rendered on their own. React 18's "automatic batching" drops that restriction: batching now depends only on the tick the code runs in, not on where it runs.

The cleanest way to see this is an await. It splits one handler into two separate ticks of the event loop:

The real code

const onClick = async () => {
  setCounter(0);                       // ┐ Batch 1  (before await)
  setCounter(1);                       // ┘
  const data = await fetchSomeData();  // ← the handler SUSPENDS here
  setCounter(2);                       // ┐ Batch 2  (after await)
  setCounter(3);                       // ┘
};

The answer in React 18 is two renders — one for Batch 1, one for Batch 2. There is no special "async batching" code; both batches use the exact same microtask-deferral path. The two simply can't merge, because await puts them in different ticks: Batch 1's microtask has already fired and rendered before the network even resolves.

── Tick A: the click ─────────────────────────────────────────────
  onClick() runs:
    setCounter(0); setCounter(1)
        → each: scheduleUpdateOnFiber → ensureRootIsScheduled
        → scheduleMicrotask(flushSyncCallbacks)   (scheduled once; 2nd call deduped)
    await fetchSomeData()
        → fetchSomeData() returns pending Promise P
        → async fn SUSPENDS, returns control to its caller
  call stack unwinds
  ▶ microtask: flushSyncCallbacks → performSyncWorkOnRoot → ⟦Batch 1: render + commit⟧

── ...network I/O... eventually P resolves ───────────────────────
── Tick B: the engine resumes the async fn (a fresh microtask) ───
    setCounter(2); setCounter(3)
        → each: scheduleUpdateOnFiber → ensureRootIsScheduled
        → scheduleMicrotask(flushSyncCallbacks)   (scheduled once; 2nd deduped)
  ▶ microtask: flushSyncCallbacks → performSyncWorkOnRoot → ⟦Batch 2: render + commit⟧

The difference from React 17 is exactly the post-await half:

The real code

// 1. Inside React event handlers — batched in both versions (1 render)
function handleClick() {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React 17 AND 18: only 1 re-render.
}

// 2. Outside React event handlers — React 17 NOT batched; React 18 batched
fetchSomething().then(() => {
  setCount(c => c + 1); // React 17: triggers Re-render #1
  setFlag(f => !f);     // React 17: triggers Re-render #2
                         // React 18: both batched into 1 render
});