Frontend Development 13 min read

How React 18’s Automatic Batching Reduces Renders and Boosts Performance

React 18 introduces automatic batching that merges multiple state updates into a single render across all contexts—including promises, timeouts, and native events—improving performance while offering opt‑out mechanisms like flushSync for rare cases where immediate DOM updates are required.

KooFE Frontend Team
KooFE Frontend Team
KooFE Frontend Team
How React 18’s Automatic Batching Reduces Renders and Boosts Performance
This article is a translation of "Automatic batching for fewer renders in React 18" by New Oriental Online frontend engineer Li Fang.

Overview

React 18 adds a new optimization feature that enables automatic batching without manual code changes, allowing more scenarios to benefit from batch updates. This article explains what batching is, how it worked before React 18, and what changed in React 18.

What is batching?

Batching means React merges multiple state updates and performs a single render, improving performance.

For example, two state updates inside the same click handler are combined into one render. The following code demonstrates that only one render occurs per click even though two state updates are called:

<code>function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    setCount(c => c + 1); // Does not re-render yet
    setFlag(f => !f); // Does not re-render yet
    // React will only re-render once at the end (that's batching!)
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{color: flag ? "blue" : "black"}}>{count}</h1>
    </div>
  );
}
</code>

✅ Demo: Batching in React 17 event handlers (check console for one log per click).

Batching improves rendering performance by avoiding unnecessary renders and prevents bugs caused by partially updated component state, similar to a waiter waiting to place a full order.

However, batching does not apply in all scenarios. If a state update occurs after an async operation (e.g., inside a fetch callback), React will perform separate renders.

In earlier React versions, only updates inside browser events were batched. The following example shows that updates inside a fetch promise are not batched:

<code>function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    fetchSomething().then(() => {
      // React 17 and earlier does NOT batch these because they run after the event
      setCount(c => c + 1); // Causes a re-render
      setFlag(f => !f); // Causes a re-render
    });
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{color: flag ? "blue" : "black"}}>{count}</h1>
    </div>
  );
}
</code>

🟡 Demo: React 17 does not batch external event handlers (check console for two logs per click).

Before React 18, only updates inside React event handlers were batched. By default, updates inside promises, setTimeout, native event handlers, or any other async context were not batched.

What is automatic batching?

Starting with React 18's

createRoot

, all updates—regardless of where they occur—are automatically batched.

This means

setTimeout

,

promises

, native events, and any other async updates are batched just like React events, reducing render work and improving performance.

<code>function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    fetchSomething().then(() => {
      // React 18 and later DOES batch these:
      setCount(c => c + 1);
      setFlag(f => !f);
      // React will only re-render once at the end (that's batching!)
    });
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{color: flag ? "blue" : "black"}}>{count}</h1>
    </div>
  );
}
</code>

✅ Demo: React 18 createRoot batches external event handlers (one console log per click).

🟡 Demo: React 18 render retains previous behavior (two console logs per click).

Note: Upgrade to React 18 and use createRoot . The old render API is kept only for simplifying experiments across versions.

Regardless of where state changes happen, React will batch them, for example:

<code>function handleClick() {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
}
</code>

Or like this:

<code>setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
}, 1000);
</code>

Or like this:

<code>fetch(/*...*/).then(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
});
</code>

Or like this:

<code>elm.addEventListener('click', () => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
});
</code>
Note: React only performs batching when it is safe—e.g., ensuring the DOM is fully updated before the next user event.

What if you don’t want batching?

Batching is safe for most code, but some cases need immediate DOM reads after a state change. In such cases, you can opt out using

ReactDOM.flushSync()

:

<code>import { flushSync } from 'react-dom'; // Note: react-dom, not react

function handleClick() {
  flushSync(() => {
    setCounter(c => c + 1);
  });
  // React has updated the DOM by now
  flushSync(() => {
    setFlag(f => !f);
  });
  // React has updated the DOM by now
}
</code>

This scenario is rare.

Impact on Hooks

If you use Hooks, batching works automatically in almost all cases.

Impact on Classes

State updates inside event callbacks have always been batched, so class components see no change there. However, an edge case exists where a class component can synchronously read state after a

setState

call inside a timeout, which React 18 eliminates because those updates are now batched.

<code>handleClick = () => {
  setTimeout(() => {
    this.setState(({count}) => ({count: count + 1}));
    // {count: 1, flag: false}
    console.log(this.state);
    this.setState(({flag}) => ({flag: !flag}));
  });
};
</code>

In React 18, the state updates inside

setTimeout

are batched, so the first

setState

does not render immediately, and the console logs the previous state.

<code>handleClick = () => {
  setTimeout(() => {
    this.setState(({count}) => ({count: count + 1}));
    // {count: 0, flag: false}
    console.log(this.state);
    this.setState(({flag}) => ({flag: !flag}));
  });
};
</code>

If this issue blocks upgrading to React 18, you can force an immediate update with

ReactDOM.flushSync

, but use it cautiously:

<code>handleClick = () => {
  setTimeout(() => {
    ReactDOM.flushSync(() => {
      this.setState(({count}) => ({count: count + 1}));
    });
    // {count: 1, flag: false}
    console.log(this.state);
    this.setState(({flag}) => ({flag: !flag}));
  });
};
</code>

Hooks‑based function components are unaffected because

useState

updates are already batched.

<code>function handleClick() {
  setTimeout(() => {
    console.log(count); // 0
    setCount(c => c + 1);
    setCount(c => c + 1);
    setCount(c => c + 1);
    console.log(count); // 0
  }, 1000);
}
</code>

In Hook function components, no extra handling is required; batching is already in place.

unstable_batchedUpdates what is it?

Some React libraries use this API to force batching of

setState

calls outside event handlers:

<code>import { unstable_batchedUpdates } from 'react-dom';

unstable_batchedUpdates(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
});
</code>

The API still exists in React 18 but is no longer needed because batching is automatic. It may be removed in a future version once mainstream libraries stop relying on it.

Translator’s note: React’s batching before version 18 was limited to specific scenarios (event callbacks). In version 18 it becomes universal, but class components lose some synchronous update capabilities, requiring flushSync to opt out. Hooks are unaffected.
performanceReactHooksReact 18Automatic BatchingflushSyncunstable_batchedUpdates
KooFE Frontend Team
Written by

KooFE Frontend Team

Follow the latest frontend updates

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.