Skip to solution
hardFrontend

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

114 views
01

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

reactreact-18rendering
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 senior React interviews — assumes state updates and renders. Difficulty: Hard

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. This doc covers the React 18 change and the opt-out; <a href="

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

Batching across contexts, and flushSync buying a DOM read
Run Playground
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>
  );
}
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 118 of 119 decoded in the React.js track. One more won't hurt.

Back to track