Skip to the solution
HardFrontend6 min read

How does automatic batching work in React 18, and how do you opt out?

114 views

The problem

Question presented to candidate: "React 18 introduced automatic batching. What exactly changed, and how would you opt out of it?"

What a strong answer should cover:

  • Before React 18, batching only applied inside React's own synthetic event handlers. Updates in setTimeout, promises, or native listeners each triggered their own render.
  • React 18 made it automatic everywhere β€” that is the whole change, and it was one of the few genuinely observable behavioural differences in the release.
  • The mechanism: updates are queued and flushed together at the end of the current work, so the component never renders with only some applied.
  • Why it was safe to change: batching is semantically invisible unless you were relying on an intermediate render, which was already fragile.
  • Opting out: flushSync β€” forces React to process an update synchronously before continuing. Costs an extra render and the batching you gave up.
  • Legitimate uses of flushSync: measuring the DOM after a state change, or integrating with a non-React system that reads the DOM immediately.
  • unstable_batchedUpdates was the pre-18 way to opt in manually; it still exists for compatibility but is no longer needed.
  • Concurrent features build on this: transitions rely on React controlling when work is flushed.

Clarifying questions expected:

  • "Which React version is the codebase on?" β€” the answer genuinely differs.
  • "Do you need the DOM updated before the next line, or just eventually?"

Code / implementation expected: Yes β€” updates across contexts, plus flushSync and what it costs.

  • react
  • react-18
  • rendering

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

6 min read Β· 9 sections

Try it in the playground

Open any example in the playground to run and change it.

ReactBatching across contexts, and flushSync buying a DOM read
import { useState, useRef } from "react";
import { flushSync } from "react-dom";

export default function App() {
  const [items, setItems] = useState(["first item"]);
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);
  const [log, setLog] = useState([]);

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

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

  // All three of these produce ONE render on React 18+. On React 17 only the
  // first would have; the other two would have produced three each.
  const inHandler = () => {
    begin();
    setA((x) => x + 1); setB((x) => x + 1); setA((x) => x + 1);
    setTimeout(() => note("3 updates in a handler"), 0);
  };
  const inTimeout = () => {
    begin();
    setTimeout(() => {
      setA((x) => x + 1); setB((x) => x + 1); setA((x) => x + 1);
      setTimeout(() => note("3 updates in setTimeout"), 0);
    }, 0);
  };
  const inPromise = () => {
    begin();
    Promise.resolve().then(() => {
      setA((x) => x + 1); setB((x) => x + 1); setA((x) => x + 1);
      setTimeout(() => note("3 updates in a promise"), 0);
    });
  };

  // Opting out costs a render per flushSync call.
  const optOut = () => {
    begin();
    flushSync(() => setA((x) => x + 1));
    flushSync(() => setB((x) => x + 1));
    setTimeout(() => note("2 updates via flushSync"), 0);
  };

  // The legitimate use: the new row must EXIST in the DOM before we scroll.
  const listRef = useRef(null);
  const addAndScroll = () => {
    flushSync(() => setItems((prev) => [...prev, "item " + (prev.length + 1)]));
    // Without flushSync the DOM would not yet contain the new row, so
    // scrollHeight would be the old value and this would under-scroll.
    listRef.current.scrollTop = listRef.current.scrollHeight;
  };

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

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 520 }}>
      <p style={{ fontSize: 14 }}>
        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={optOut} style={btn}>flushSync x2</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" }}>The legitimate use: add a row, then scroll to it</h4>
      <div ref={listRef} style={{ height: 90, overflow: "auto", border: "1px solid #ddd", borderRadius: 6, padding: 6 }}>
        {items.map((it, i) => <div key={i} style={{ fontSize: 13 }}>{it}</div>)}
      </div>
      <p><button onClick={addAndScroll}>add and scroll to bottom</button></p>
      <p style={{ color: "#666", fontSize: 13 }}>
        The scroll works because flushSync committed the new row to the DOM
        before the next line read scrollHeight.
      </p>
    </div>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 118 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextHard

What changed with Context in React 19 (`<Context>` as a provider)?

Continue