State Update Batching in React

A few weeks ago, a discussion that I've had with one of my colleagues led me to write about a (kind-of) hidden React feature: state update batching.

What is it?

In most basic terms, it is grouping (batching) several React state updates into one re-render. Check this React component for an example:

const App = (): JSX.Element => {
  const [clickCount, setClickCount] = useState(0);
  const [isDarkModeEnabled, setIsDarkModeEnabled] = useState(false);

  const toggle = (): void => {
    setClickCount(clickCount + 1);
    setIsDarkModeEnabled(!isDarkModeEnabled);
  };

  console.log(clickCount, isDarkModeEnabled);

  return (
    <div>
      <p>
        clickCount: {clickCount}
        <br />
        isDarkModeEnabled: {isDarkModeEnabled.toString()}
      </p>
      <button onClick={toggle}>Toggle</button>
    </div>
  );
};

Here we have a click event handler to update both clickCount and isDarkModeEnabled states. How many times do you think it will be logged to the console when you click the button?

The answer is 1, not that tricky - you can try here if you want. This is one of the most basic examples of how batching works in React.

What is the catch?

Although it looks plain and simple, things get a bit more complicated when we try different things:

const App = (): JSX.Element => {
  ...

  const toggleWithSetTimeout = (): void => {
    setTimeout(() => {
      setClickCount(clickCount + 1);
      setIsDarkModeEnabled(!isDarkModeEnabled);
    }, 1000);
  };

  console.log(clickCount, isDarkModeEnabled);

  return (
    <div>
      ...
      <button onClick={toggleWithSetTimeout}>Toggle with setTimeout</button>
    </div>
  );
};

In this case, we update the state with a timeout. You might think that it will log once again. The answer is: it does and it doesn't. It depends on your React version. In React 17 and the earlier versions, this code will log twice. And with React 18, this code will log only once. This is because, until version 18, React has been batching state updates for only React event handlers. So timeouts, promises, or even native event handlers (and more) were not being batched. You can see it for yourself.

Aand, of course, it works magically in React 18, you can find the sandbox with the same code but with the newer version here.

Why am I talking about this?

I believe that batching is one of the not well-known features of React. This opinion is based on interactions I had with different people at different times in the past about this. It is important to know about this because of another feature of React: prevState syntax for the setState function.

Independent of your React version, there is a really high chance that some state update batching is happening somewhere in your React application. And if you are not careful about it, it can introduce bugs for your application. Let's assume we have a big function that updates the same state using the state itself more than once:

const [sum, setSum] = useState(0);

...

const calculateSum = () => {
  setSum(1000);
  setSum(sum + 1000)
  setSum(sum + 2000)
}

If the state updates in calculateSum function are batched, the sum state will be set to 2000 (actually depends if we've already updated the state in another place, but let's assume we didn't) instead of 4000 because in the current closure, sum is equal to 0.

However, if we do the same thing using the prevState syntax, it works as expected:

const calculateSumWithPrevState = () => {
  setSum(1000);
  setSum((prevSum) => prevSum + 1000);
  setSum((prevSum) => prevSum + 2000);
};

You can try it out yourself here.

TL;DR

Use prevState syntax of the setState function if you are calculating the next state using the current state value!