skip to content

An app is being upgraded from React 17 to React 19. In React 17, two state updates inside a `.then()` callback or a `setTimeout` produced two re-renders; now they produce one. What changed, and what did teams do before that to get batching in async code?

level: middleimportance: must knowfreq 60%

answer

  1. the old batch window was React's own dispatch
  2. promises and timers were outside it
  3. gated on the root, not the version
  4. the legacy escape hatch went the other way
  5. only intermediate-render assumptions break

basics

~20 s

React 18 extended batching to every update, not just those inside React event handlers, for apps mounted with createRoot; React 19 keeps that and removes the legacy root entirely. Before 18, teams wrapped async updates in ReactDOM.unstable_batchedUpdates.

solid answer

~50 s

React 17 batched only updates queued inside its own synthetic event handlers. Anything queued later — in a `.then()`, a `setTimeout`, a native `addEventListener` callback — fell outside that window and each setter triggered its own render. React 18 made batching automatic everywhere, which is exactly what the "automatic" in automatic batching refers to. The switch was tied to the root API: apps mounted with `createRoot` got the new behavior, while the legacy `ReactDOM.render` root kept the old one, so an upgrade that only bumped the version changed nothing until the root was migrated. React 19 removes the legacy root, so the new behavior is the only behavior. Before React 18, the supported workaround was to wrap the async code in `ReactDOM.unstable_batchedUpdates`. Today the only reason to think about it is the opposite direction: `flushSync` to opt *out*.

code

jsx · 23 lines
jsx
import { useState, useEffect } from 'react';

export default function Loader() {
  const [loading, setLoading] = useState(true);
  const [user, setUser] = useState(null);

  useEffect(() => {
    let cancelled = false;
    fetch('/api/user')
      .then((r) => r.json())
      .then((data) => {
        if (cancelled) return;
        // React 17: two renders. React 18+ with createRoot: one.
        setUser(data);
        setLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, []);

  return <p>{loading ? 'loading' : user.name}</p>;
}

go deeper

for a junior

Know that in current React, state updates are batched wherever they happen — in a promise, a timer, or a handler — and that older React batched only inside its own event handlers.

for a middle

Explain that the batch boundary moved from React's event dispatch to any scheduled work, that React 18 gated it on createRoot, and that React 19 removes the legacy root so it is unconditional.

for a senior

Be able to predict what an upgrade actually breaks: assertions and side effects that relied on an intermediate render, plus the judgment that the fix is a narrow flushSync, never restoring per-setter renders.

for a principal

Own the migration framing: this is a behavior change that lands in untouched code, so plan for render-count-sensitive tests and instrumentation, and treat any surviving opt-out as a documented exception rather than a pattern.

## What React 17 actually did React has batched updates for a long time, but pre-18 the batching window was tied to React's own event dispatch. When React handled a `click` on a component, it opened a batch, ran your handler, and flushed once at the end. Anything that queued updates *outside* that dispatch had no batch to join, so each setter scheduled and flushed its own render: ```jsx // React 17 behaviour fetch('/api/user').then(() => { setLoading(false); // render 1 setUser(data); // render 2 }); setTimeout(() => { setA(1); // render 1 setB(2); // render 2 }, 0); ``` The same was true of listeners you attached yourself with `addEventListener`, and of callbacks handed to you by non-React libraries. This was not a documented guarantee so much as an artefact of *where* the batch was opened, and it produced two familiar symptoms: extra render passes after every data fetch, and a brief flash where one piece of state had landed and the other had not. The escape hatch was explicit. `ReactDOM.unstable_batchedUpdates(fn)` opened a batch around arbitrary code, and library authors — Redux among them — called it internally so that a store notification updating several subscribed components produced one render instead of one per component. ## What React 18 changed React 18 made batching *automatic*: updates are batched no matter where they are queued — promise callbacks, timers, native event listeners, `queueMicrotask` callbacks, anywhere. The batch boundary became the scheduled work itself rather than React's event dispatch. The crucial upgrade detail is that this was gated on the root API, not on the package version: ```jsx // legacy root: React 18 kept the OLD, event-handler-only batching ReactDOM.render(<App />, container); // new root: automatic batching everywhere import { createRoot } from 'react-dom/client'; createRoot(container).render(<App />); ``` So a team that bumped to 18 without migrating the root saw none of this. React 19 removes `ReactDOM.render` and the legacy root entirely, so `createRoot` — and therefore automatic batching everywhere — is the only option. That is why the behavior difference shows up as an *upgrade* question: it is one of the few changes that can alter render counts in code you did not touch. ## What breaks on upgrade Almost nothing, but the failure mode is worth naming, because interviewers like it. Code breaks when it *depended* on an intermediate render: - An effect that was expected to run twice — once after each setter — now runs once. If it was doing something per-run (logging, an analytics ping, an imperative call), the count changes. - Code that queued a setter and then read the DOM, relying on the previous update having already been committed by the earlier unbatched render. - Tests asserting on render counts or on a snapshot taken between the two setters. The fix is essentially never to restore per-setter rendering. If one specific spot genuinely needs the DOM updated before the next line, wrap that update in `flushSync` from `react-dom`; everywhere else, the batched behavior is the correct one and the assertion was encoding an accident. ## The direction of the escape hatches It helps to hold the two APIs as opposites on a timeline: - `unstable_batchedUpdates` — the React 17-era tool to *get* batching where React did not give it to you. Obsolete as an application concern once automatic batching landed. - `flushSync` — the modern tool to *give up* batching for one specific update, forcing a synchronous render and commit. A candidate who reaches for the first in a React 19 codebase, or who thinks the second is a general performance tool, is signalling that their model stopped updating around React 17. ## Framing it in an interview Say what the old boundary was (React's own event dispatch), what the new one is (any scheduled work), that the switch rode on `createRoot` and is unconditional in 19, and that the only code that notices is code that was relying on an intermediate render. That is the complete answer, and it lands in under a minute.

  • A team bumped to React 18 and reported that async updates were still not batched. What would you check first?
    Whether the app was still mounted with the legacy `ReactDOM.render` root. In React 18 automatic batching shipped only for roots created with `createRoot` from `react-dom/client`; the legacy root deliberately preserved the old behavior for compatibility. Migrating the root entry point is the fix. In React 19 the question disappears, because the legacy root no longer exists.
  • Name a concrete piece of code that genuinely behaves differently after the upgrade.
    Anything counting renders or effect runs. An effect with a dependency on two state values that both change in a promise callback used to run twice and now runs once, so a per-run side effect — an analytics event, an imperative library call, a test assertion on call count — changes its numbers. That is almost always a latent bug being exposed, not a regression to work around.
  • If one specific async update must be committed before the next line runs, what do you use in React 19?
    `flushSync` from `react-dom`, wrapping only that update. It forces React to render and commit synchronously before returning, so the DOM reflects the change on the next line. It is deliberately narrow: it costs a synchronous render and gives up the scheduling benefits of batching, so you scope it to the one call that needs it rather than to the whole callback.

saying these in an interview costs you the question

  • Thinks React has always batched updates everywhere
  • Says upgrading the version alone enabled automatic batching in 18
  • Recommends unstable_batchedUpdates in a modern codebase
  • Claims the change was about making setState synchronous
  • Assumes native addEventListener callbacks are still unbatched

context