Dec 17, 2021
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>
);
}
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>
);
};
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>
);
};
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>
);
};
💡 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 />);
➡️ 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! 😃
Newsletter
Subscribe to my mailing list
Subscribe to get my latest content by email. I won't send you spam, I promise. Unsubscribe at any time.