Skip to solution
mediumFrontend

How does React's Batching work?

905 views
01

Understand the problem

Question presented to candidate: "If you call three setters in one event handler, how many times does the component render? And does that change inside a setTimeout?"

What a strong answer should cover:

  • Batching groups multiple state updates into a single re-render, so the UI never shows a half-applied intermediate state.
  • Automatic batching since React 18 applies everywhere — event handlers, setTimeout, promises, native event listeners. Before 18 it only applied inside React event handlers.
  • That was one of the more visible behavioural changes in React 18, and a common source of "this used to render twice" surprises.
  • State updates are asynchronous in the sense that the variable does not change until the next render — reading it immediately after a setter gives the old value.
  • The functional updater is how you compose several updates to the same value: setN(n => n + 1) three times increments by three, where setN(n + 1) three times increments by one.
  • flushSync opts out, forcing a synchronous render — an escape hatch for measuring the DOM between updates, at the cost of an extra render and lost batching.
  • Why it exists: fewer renders, and no intermediate frames where two related pieces of state disagree.

Clarifying questions expected:

  • "Which React version?" — the answer genuinely differs before and after 18.
  • "Are the updates to the same value or different ones?" — that decides whether a functional updater is needed.

Code / implementation expected: Yes — updates in a handler, in a timeout, and with a functional updater versus a stale read.

stateperformancerendering
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for React interviews — assumes useState. Difficulty: Medium

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. Every render count below was measured on React 19.2.8 by clicking real butt

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

Counting renders across handlers, timeouts and promises
Run Playground
import { useState, useRef } from "react";
import { flushSync } from "react-dom";

export default function App() {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);
  const [naive, setNaive] = useState(0);
  const [functional, setFunctional] = useState(0);
  const [log, setLog] = useState([]);

  const renders = useRef(0);
  renders.current++;
  const renderAtStart = useRef(0);

  const mark = (label) => {
    const used = renders.current - renderAtStart.current;
    setLog((l) => [...l.slice(-4), label + " -> " + used + " render(s)"]);
  };
  const begin = () => { renderAtStart.current = renders.current; };

  // Three updates in a handler: one render.
  const inHandler = () => {
    begin();
    setA((x) => x + 1); setB((x) => x + 1); setA((x) => x + 1);
    setTimeout(() => mark("3 updates in a handler"), 0);
  };

  // Since React 18 this is ALSO one render. On React 17 it was three.
  const inTimeout = () => {
    begin();
    setTimeout(() => {
      setA((x) => x + 1); setB((x) => x + 1); setA((x) => x + 1);
      setTimeout(() => mark("3 updates in setTimeout"), 0);
    }, 0);
  };

  // Also one render.
  const inPromise = () => {
    begin();
    Promise.resolve().then(() => {
      setA((x) => x + 1); setB((x) => x + 1); setA((x) => x + 1);
      setTimeout(() => mark("3 updates in a promise"), 0);
    });
  };

  // flushSync forces each update through immediately — batching opted out.
  const withFlush = () => {
    begin();
    flushSync(() => setA((x) => x + 1));
    flushSync(() => setB((x) => x + 1));
    setTimeout(() => mark("2 updates with flushSync"), 0);
  };

  // The classic bug: all three read the SAME naive value from this render.
  const bumpNaive = () => { setNaive(naive + 1); setNaive(naive + 1); setNaive(naive + 1); };
  // The fix: each update applies to the result of the previous one.
  const bumpFunctional = () => {
    setFunctional((n) => n + 1); setFunctional((n) => n + 1); setFunctional((n) => n + 1);
  };

  const btn = { marginRight: 6, marginBottom: 6 };

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 520 }}>
      <p style={{ fontSize: 14 }}>
        component renders: <strong>{renders.current}</strong> · a={a} b={b}
      </p>

      <p>
        <button onClick={inHandler} style={btn}>handler</button>
        <button onClick={inTimeout} style={btn}>setTimeout</button>
        <button onClick={inPromise} style={btn}>promise</button>
        <button onClick={withFlush} style={btn}>flushSync</button>
      </p>

      <pre style={{ background: "#f6f6f6", padding: 10, borderRadius: 6, fontSize: 12 }}>
        {log.length ? log.join("\n") : "(press a button)"}
      </pre>

      <h4 style={{ margin: "12px 0 6px" }}>Three increments, two ways</h4>
      <p style={{ fontSize: 14 }}>
        <button onClick={bumpNaive} style={btn}>setNaive(naive + 1) x3</button>
        <strong>{naive}</strong> — climbs by 1
      </p>
      <p style={{ fontSize: 14 }}>
        <button onClick={bumpFunctional} style={btn}>setFunctional(n =&gt; n + 1) x3</button>
        <strong>{functional}</strong> — climbs by 3
      </p>
    </div>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 43 of 119 decoded in the React.js track. One more won't hurt.

Back to track