Dec 17, 2021

React 18: Automatic Batching

React 18 Automatic Batching

I'm sure you have heard the term "React Batching" in the past, and maybe you were confused (as I was) at what it means and what it actually does in practical terms.

Generally speaking the word Batching can be used in various ways but a general definition that makes sense for our use case can be expressed as:

A group of jobs, data, or programs treated as a unit for computer processing.

(Batch Processing) A form of data processing in which a number of input jobs are grouped for processing during the same machine run.

What is React Batching

Basically, when React applies "Batching" it means that it groups together multiple state updates into a single re-render mainly for better performance.

In React 17 and prior, React automatically batches any state updates only inside React event handlers (like a click or change).

Take a look at the code below and guess how many re-renders will the handleClick function cause?

Also don't worry about the useRenderCount function. It's just a custom hook to count re-renders :-)

const useRenderCount = () => {
  const renderCounter = useRef(0); 
  useLayoutEffect(() => {
    renderCounter.current++;
  });
  return renderCounter.current;
};

The actual demo:

const Demo1 = () => {
    const renderCount = useRenderCount(); // we use this to count re-renders
    const [countUp, setCountUp] = useState(0);
    const [countDown, setCountDown] = useState(0);

    const handleClick = () => {
        setCountUp((cUp) => cUp + 1);
        setCountDown((cDown) => cDown - 1);
    };

    return (
        <div style={{ textAlign: "center" }}>
            <h4>Demo 1 (Batching)</h4>
            <button onClick={handleClick}>Click me</button>
            <h2>Count up: {countUp}</h2>
            <h2>Count down: {countDown}</h2>
            <div>Number of rerenders: {renderCount}</div>
        </div>
    );
}

Demo 1 (Batching)

That's right, it will only cause one re-render because React will batch the two state updates setCountUp and setCountDown into one!

Pretty cool right? React will take care that for us automatically and will avoid unnecessary re-renders which is great for performance! Also it will prevent our components from rendering "half-finished" states where only one state variable was updated, which may cause bugs.

✋ But... There is a catch

React (prior to version 18) will only batch React event handlers. It will not batch updates inside of promises, setTimeout, native event handlers or any other events.

In the real world there are many use cases where we need to update multiple states inside a handler where React cannot batch.

Let's take a look at some examples below where React will not batch updates and it will cause more than one re-render.

1) Updates inside setTimeout

In the demo below, setCountUp and setCountDown will cause two re-renders as React cannot batch multiple updates inside a setTimeout callback.

const Demo2 = () => {
  const renderCount = useRenderCount();
  const [countUp, setCountUp] = useState(0);
  const [countDown, setCountDown] = useState(0);

  const handleClick = () => {
    setTimeout(() => {
      setCountUp((cUp) => cUp + 1);
      setCountDown((cDown) => cDown - 1);
    }, 500);
  };

  return (
    <div className="Demo">
      <h4>Demo 2 (setTimeout)</h4>
      <button onClick={handleClick}>Click me</button>
      <h2>Count up: {countUp}</h2>
      <h2>Count down: {countDown}</h2>
      <div>Number of rerenders: {renderCount}</div>
    </div>
  );
};

Demo 2 (setTimeout)

2) Updates inside a promise

In the example below we fetch a random cat image when the button is clicked. React cannot batch the multiple state updates that's after the await fetch call, specifically the setCat(json.url) and setLoading(false).

However, it successfully batches the first two calls before the await: setLoading(true) and setCat(null).

So in total the handleClick function will cause 3 re-renders.

const Demo3 = () => {
  const renderCount = useRenderCount();
  const [loading, setLoading] = useState(false);
  const [cat, setCat] = useState(null);

  const handleClick = async () => {
    // these two below will be batched
    setLoading(true);
    setCat(null);
    const response = await fetch("https://thatcopy.pw/catapi/rest/");
    if (response.ok) {
      const json = await response.json();
      setCat(json.url); // cannot batch
    }
    setLoading(false); // cannot batch
  };

  return (
    <div className="Demo">
      <h4>Demo 3 (Promise)</h4>
      <button onClick={handleClick}>Click me</button>
      <h2>Loading: {String(loading)}</h2>
      <h2
        style={{
          display: "flex",
          justifyContent: "center",
          alignItems: "center"
        }}
      >
        Cat:{" "}
        {cat ? (
          <img
            alt="cat"
            width="35"
            height="35"
            src={cat}
            style={{ marginLeft: "8px" }}
          />
        ) : (
          ""
        )}
      </h2>
      <div>Number of rerenders: {renderCount}</div>
    </div>
  );
};

Demo 3 (Promise)

3) Updates inside a native event handler

In the example below we use a native event handler by attaching a click listener to our button. React is not able to batch these multiple state updates as well.

const Demo4 = () => {
  const renderCount = useRenderCount();
  const [countUp, setCountUp] = useState(0);
  const [countDown, setCountDown] = useState(0);

  useEffect(() => {
    const handleClick = () => {
      setCountUp((cUp) => cUp + 1);
      setCountDown((cDown) => cDown - 1);
    };

    const element = document.querySelector("#my-button");
    element.addEventListener("click", handleClick);

    return () => {
      element.removeEventListener('click', handleClick);
    };
  }, []);

  return (
    <div className="Demo">
      <h4>Demo 4 (Native event handler)</h4>
      <button id="my-button">Click me</button>
      <h2>Count up: {countUp}</h2>
      <h2>Count down: {countDown}</h2>
      <div>Number of rerenders: {renderCount}</div>
    </div>
  );
};

Demo 4 (Native event handler)

💡 Quick fix to the above scenarios

Obviously, a fix to the above scenarios would be to store all the "individual" states to a big state (or use a useReducer).

For example, the Demo with the fetch promise could be written as shown below in order to prevent unwanted re-renders:

const Demo3Fix = () => {
  const renderCount = useRenderCount();
  const [state, setState] = useState({
    loading: false,
    cat: null
  }); // a single state object

  const handleClick = async () => {
    setState({
      loading: true,
      cat: null
    }); // 1st re-render
    const response = await fetch("https://thatcopy.pw/catapi/rest/");
    if (response.ok) {
      const json = await response.json();
      setState((s) => ({ cat: json.url, loading: false })); // 2nd re-render
    } else {
      setState((s) => ({ ...s, loading: false })); // or this 2nd re-render
    }
  };

  const { cat, loading } = state;

  return (
    ...
  );
};

➡️ All the previous React 17 demos are available in this CodeSandbox: https://codesandbox.io/s/cranky-mccarthy-g5ntm

React 18 to the rescue 🎉

React 18 adds automatic batching for all use cases to improve performance even further. You can read more here. It's still in beta.

That means that for all of our examples before, React (18) will automatically batch any multiple state updates into single update.

For proof, take a look at the setTimeout demo below where the only thing we changed was the version of React to 18 and the code inside index.js where we instruct React how to load our app. Specifically in order for React 18 to utilize these new features, we need to make use of the createRoot api.

index.js in React 17:

import { render } from "react-dom";
import App from "./App";

render(<App />, document.getElementById("root"));

index.js in React 18:

import { createRoot } from "react-dom";
import App from "./App";

createRoot(document.getElementById("root")).render(<App />);

Demo React 18 (setTimeout)

➡️ Here's the code with all the experiments for React 18: https://codesandbox.io/s/objective-elbakyan-6u76m

What if you don't want to batch?

There may be a case where we don't want React to batch the updates (although generally batching is considered safe). We can achieve that by using flushSync function like shown below:

import { flushSync } from 'react-dom'; 

const handleClick = () => {
  flushSync(() => {
    setCountUp((cUp) => cUp + 1);  // 1st re-render 
  })
  flushSync(() => { 
    setCountDown((cDown) => cDown - 1); // 2nd re-render
  })  
};

Also it's worth noting here that in React version prior to 18 we could force state updates outside of React event handlers to be batched by using the undocumented API unstable_batchedUpdates like this:

unstable_batchedUpdates(() => {
  // these 2 below will be batched
  setCountUp((cUp) => cUp + 1);
  setCountDown((cDown) => cDown - 1);
});

According to the docs this API still exists in 18, but it isn't necessary anymore because batching happens automatically. It also might get removed in a future version.

The End

I hope you enjoyed this article, catch you later! 😃