Difficulty: Intermediate
What is automatic batching in React 18? How does it differ from React 17?
Okay, automatic batching is one of those React 18 features that most developers benefit from without even realizing it. Let me explain what it is and why it matters.
Imagine you have a button click handler that updates three different pieces of state: setLoading(true), setData(newData), and setError(null). Without batching, each of those setState calls would trigger a separate re-render. That means your component renders three times in quick succession, the browser repaints three times, and any child components also re-render three times. That is wasteful -- the user only needs to see the final result, not the intermediate states.
Batching is React's way of grouping multiple state updates into a single re-render. Instead of render-render-render, you get one render with all three state changes applied together. It is like waiting to submit your entire grocery list at once instead of making three separate trips to the store.
Now here is where React 17 and React 18 diverge significantly. In React 17, batching only worked inside React event handlers. If you called setState inside an onClick, onChange, or onSubmit handler, React would batch those updates. But if you called setState inside a setTimeout, a Promise .then() callback, a fetch response handler, or a native DOM event listener (like addEventListener), each setState triggered its own separate re-render. This was a real pain point because a huge amount of real-world code involves async operations.
React 18 fixed this with automatic batching. Now, state updates are batched everywhere -- inside setTimeout, inside Promise callbacks, inside fetch().then(), inside native event listeners, everywhere. This is a free performance win that you get just by upgrading to React 18 and using createRoot instead of the old ReactDOM.render.
Let me paint a real-world picture. You have a component that fetches user data. After the fetch completes, you call setUser(data), setLoading(false), and setError(null). In React 17, that is three re-renders because it is inside an async callback. In React 18, it is one re-render. Multiply this across your entire app with dozens of async operations, and you can see how this adds up to a meaningful performance improvement.
But what if you actually NEED a state update to happen immediately and trigger a re-render before the next line of code? That is where flushSync comes in. flushSync is the escape hatch from batching. You import it from react-dom and wrap your setState call: flushSync(() => { setState(newValue) }). This forces React to flush the update synchronously -- the DOM is updated immediately, and you can read the updated DOM on the very next line.
The canonical example for flushSync is a chat application. When you add a new message to the list, you want to scroll to the bottom. But if the state update is batched, the new message has not been added to the DOM yet by the time your scroll code runs. So you use flushSync to force the message into the DOM immediately, then scroll to it.
Here is an important nuance: use flushSync sparingly. It defeats the purpose of batching, which means more re-renders and worse performance. In most cases, you do not need it. The vast majority of your state updates are perfectly fine being batched.
Another thing worth knowing: to get automatic batching, you must use createRoot (the React 18 way) to mount your app. If you are still using the legacy ReactDOM.render, you are stuck with React 17 batching behavior even if you are on React 18. This is intentional -- React 18 maintains backward compatibility through the legacy API.
One common misconception: batching does NOT change the order of state updates. If you call setA(1) then setB(2), A will be 1 and B will be 2 in the next render. Batching just means both updates are applied together in a single render cycle instead of two separate ones. The state values themselves are always correct and in order.
Also, batching applies within a single synchronous call stack. Two completely separate async operations (like two different setTimeout callbacks firing at different times) will still cause two separate renders. Batching groups updates that happen together in the same synchronous execution context.
// React 17 - no batching in setTimeout
function Counter17() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
// Inside React event handler - batched in both v17 and v18
setCount(c => c + 1);
setFlag(f => !f);
// 1 re-render (batched)
}
function handleTimeout() {
setTimeout(() => {
// React 17: 2 re-renders (not batched!)
// React 18: 1 re-render (auto-batched!)
setCount(c => c + 1);
setFlag(f => !f);
}, 0);
}
return <button onClick={handleTimeout}>Click</button>;
}
// React 18 - batched everywhere
async function fetchAndUpdate() {
const data = await fetchSomeData();
// React 17: 2 re-renders
// React 18: 1 re-render (auto-batched)
setUser(data.user);
setLoading(false);
setError(null);
}
React 18 batches state updates even in async contexts. This can improve performance but may require flushSync in rare cases where immediate DOM updates are needed.
import { flushSync } from 'react-dom';
function Component() {
const [count, setCount] = useState(0);
const divRef = useRef(null);
function handleClick() {
// Force synchronous update to read DOM immediately after
flushSync(() => {
setCount(c => c + 1);
});
// DOM is updated here - can read it
console.log(divRef.current.textContent);
// This update is separate
flushSync(() => {
setCount(c => c + 1);
});
}
return <div ref={divRef}>{count}</div>;
}
// Practical use: scrolling to bottom after adding a message
function Chat({ messages }) {
const bottomRef = useRef(null);
function sendMessage(text) {
flushSync(() => {
setMessages(m => [...m, { text }]);
});
// Now we can scroll - DOM is updated
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}
flushSync forces React to flush state updates synchronously, allowing you to read the updated DOM immediately. The chat scroll pattern is the canonical use case.
Batching, React 18, State Updates, flushSync, Concurrent