background-shape
React 18 Automatic Batching, One Render Pass For All Your State Updates
April 8, 2022 · 5 min read · by Muhammad Amal programming

TL;DR — React 18 batches state updates from promises, setTimeout, native event handlers — anywhere. React 17 only batched inside React event handlers. Most apps get a free perf win. Opt out with flushSync when you need a DOM update to happen synchronously.

Quick post today on the React 18 change that requires the least explanation and gives the most-immediate benefit: automatic batching. It’s the change you don’t have to think about; it just makes your existing code faster.

What batching means

When state updates happen in the same tick, React combines them into a single re-render. So:

function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  function handleClick() {
    setCount(c => c + 1);
    setName('Alice');
    // React 17 inside an event handler: batched → 1 re-render
    // React 17 inside a setTimeout: NOT batched → 2 re-renders
  }
}

Batching means React doesn’t re-render after each setX call. It waits until you’re done with the current tick, then renders once with all the updated state.

This is what React calls “automatic batching.” It’s automatic because you don’t opt in. It’s batching because multiple updates collapse to one render.

What changed in React 18

React 17 batched updates only inside React-managed event handlers (onClick, onChange, etc.). Updates inside promises, setTimeout, native event listeners, etc. were NOT batched — each setX triggered a separate re-render.

// React 17 behaviour
function handleClick() {
  fetch('/api').then(() => {
    setCount(c => c + 1);    // re-render #1
    setName('Alice');         // re-render #2
  });

  setTimeout(() => {
    setCount(c => c + 1);    // re-render #3
    setName('Bob');           // re-render #4
  }, 0);
}

Four re-renders total. Each one runs your components, your useEffects, etc.

React 18 batches them all. Same code, one re-render per “phase”:

// React 18 behaviour
function handleClick() {
  fetch('/api').then(() => {
    setCount(c => c + 1);    // batched
    setName('Alice');         // batched
    // single re-render after this block
  });

  setTimeout(() => {
    setCount(c => c + 1);    // batched
    setName('Bob');           // batched
    // single re-render after this block
  }, 0);
}

Two re-renders total instead of four. Same correctness, fewer wasted renders.

What this fixes (without you noticing)

Almost every “we have weird intermediate render with half-updated state” bug. Common shape:

function loadAndUpdate() {
  fetchData().then(data => {
    setData(data);
    setLoading(false);
    setError(null);
  });
}

In React 17, that’s three re-renders. The intermediate ones (where data is set but loading hasn’t flipped to false yet) sometimes caused visible glitches in well-written components. In React 18, one re-render with all three values updated.

Almost no app needs to do anything to get this. It just works after the upgrade.

When automatic batching bites you

Rare but real. The case: you need to read the DOM (or trigger a measurement) after a state update has actually been flushed.

function App() {
  const [tab, setTab] = useState('home');

  function switchTab(next) {
    setTab(next);
    // Bug: scrollIntoView happens BEFORE React renders the new tab.
    document.getElementById(next).scrollIntoView();
  }
}

In React 17, the synchronous update meant scrollIntoView ran on the new DOM. In React 18, the batched update means it runs on the old DOM (the new content isn’t there yet).

Fix: flushSync.

import { flushSync } from 'react-dom';

function switchTab(next) {
  flushSync(() => {
    setTab(next);
  });
  document.getElementById(next).scrollIntoView();
}

flushSync forces React to render synchronously inside the callback. After it returns, the DOM is updated. The scroll then works.

Use sparingly. flushSync defeats batching for everything inside it, including unrelated updates. It’s a fix for specific imperative-DOM-needs-updated-state scenarios, not a tool to use casually.

Concrete perf wins

For a typical app upgrading from React 17 to React 18, automatic batching saves wasted renders in a few common shapes:

  • Async data loaders that set 2–4 state pieces after the fetch resolves
  • WebSocket / SSE event handlers that update multiple state pieces
  • Native event handlers (rare in React, but exist for things like custom drag-and-drop)
  • Animation frame callbacks that update tween + flag state

Across our app the post-upgrade RUM showed a ~5% reduction in average JS execution time per interaction. Modest but real. Free.

Opting out at the call site

flushSync is the only way to opt out per-call. There’s no “disable automatic batching globally” — that would defeat the whole point. If you have legacy code that genuinely relied on un-batched updates, you have two choices:

  1. Wrap the call site in flushSync.
  2. Refactor to not depend on intermediate render state.

Almost always (2) is the right move. Code that depends on intermediate render states is fragile by nature.

Common Pitfalls

Using flushSync to “force” state to be visible to the next line. State updates are async-by-design. flushSync syncs DOM rendering, but if you’re reading the state value on the next line, you’ll still get the pre-update value — setX doesn’t mutate the closure’s x. Use a local variable.

Putting flushSync around fetch results to “make sure we re-render.” Unnecessary. Promise resolution + setX already triggers re-render; the batching just makes it efficient.

Treating flushSync as cheap. It’s not. It breaks batching, runs an extra render, and disables some concurrent optimizations. Reach for it only when imperative DOM code requires synchronous render.

Assuming useEffect runs between batched updates. It doesn’t. Effects run after the batched render. If you depended on effects running per-setX in React 17, you’ll see different behaviour.

Mixed renderers (e.g., react-three-fiber). Different renderers have their own batching rules. The React 18 changes apply to react-dom and react-native. Third-party renderers may differ.

Wrapping Up

Automatic batching is the React 18 feature that requires the least from you and gives the most reliable win. Upgrade, audit for any code that depended on un-batched behaviour (rare), use flushSync for the genuine cases, otherwise enjoy fewer re-renders for free. Monday: the new React 18 hooks — useId, useSyncExternalStore, useInsertionEffect, when each one matters.