React 18 adds automatic batching


Batching is a React feature that combines all the state updates into a single update, causing a single re-render thereby improving the performance of the app. In earlier versions of React, batching was only done for the event handlers.

Before

Let’s take the following example

import React, { useState } from "react";

export default function App() {
  const [counter1, setCounter1] = useState(0);
  const [counter2, setCounter2] = useState(0);

  const handleClick = () => {
    setCounter1(counter1 + 1);
    setCounter2(counter2 + 1);
  }

  console.log('Rerendered');

  return (
    <div className='App'>
      <button onClick={handleClick}>Click me</button>
    </div>
  );
}

On clicking the button, we see the following output in the console

Rerendered

Notice that even though the state updates twice, once for counter1 and once for counter2, the app is re-rendered only once.

But in the case of asynchronous state updates, state updates are not batched, as seen in the following example.

import React, { useState } from "react";

export default function App() {
  const [counter1, setCounter1] = useState(0);
  const [counter2, setCounter2] = useState(0);
 
  const handleClick = () => {
    Promise.resolve().then(() => {
      setCounter1(counter1 + 1);
      setCounter2(counter2 + 1);
    })
  }

  console.log('Rerendered');
  
  return (
    <div className='App'>
      <button onClick={handleClick}>Click me</button>
    </div>
  );
}

Clicking the button gives the following output in the console.

Rerendered
Rerendered

Each state update causes a re-render.

It is because until React 18, React only batched updates in React event handlers. Updates inside promises, setTimeout, native event handlers or any other event were not batched in React by default.

After

Starting in React 18 with createRoot, all updates will be automatically batched, no matter where they originate from.

Runinng the above code with React 18 by upgrading to createRoot gives the console output as below

Rerendered

The old behaviour is still maintained when using React 18 with ReactDom.render

Automatic batching impact on Classes and Hooks

It was possible to synchronously read state updates inside of events in class components. This is because React mutated this.state to point to the updated state in the middle of our function.

This is not the case in hooks as there is no this to mutate.

With the createRoot changes in React 18, it is not possible to get an updated state in between the calls to setState.

Let’s modify the above example using the class component as below -

  class App extends React.Component {
    state = {
      counter1: 0,
      counter2: 0
    }

    handleClick = () => {
      setTimeout(() => {
        this.setState(({ counter1 }) => ({ counter1: counter1 + 1 }));
        console.log(this.state);
        //React 17 : { counter1: 1, counter2: 0 }
        //React 18 with createRoot : { counter1: 0, counter2: 0 } 
        this.setState(({ counter2 }) => ({ counter2: counter2 + 1 }));
      });
    };
  
    render() {
      console.log('Rerendered');
      return (
        <div className='App'>
          <button onClick={this.handleClick}>Click me</button>
        </div>
      )
    }
  }
  export default App;

We can see from the above example that we get the updated state { counter1: 1, counter2: 0 } before React 18, whereas with React 18, the state remains the same { counter1: 0, counter2: 0 }.

For some cases where we want to read something from the DOM immediately after a state change, we can opt-out of batching with ReactDOM.flushSync() as seen below.

import React, { useState } from "react";
import { flushSync } from "react-dom";

export default function App() {
  const [counter1, setCounter1] = useState(0);
  const [counter2, setCounter2] = useState(0);

  const handleClick = () => {
    flushSync(() => {
      setCounter1(counter1 + 1);
    })
    setCounter2(counter2 + 1);
  }

  return (
    <div>
      <button onClick={handleClick}>Click me</button>
    </div>
  );
}

It gives the below output

Rerendered
Rerendered

Note:

The unstable_batchedUpdates API is used by some React libraries to force setState to be batched outside of event handlers.

  import { unstable_batchedUpdates } from 'react-dom';
  
  unstable_batchedUpdates(() => {
    setCounter1(counter1 + 1);
    setCounter2(counter2 + 1);
  });

This API is still present in React 18 to ease migration. However, it is redundant due to automatic batching and will be removed in future versions.

To know more about automatic batching, check out the WG discussion.