Using React 18 Automatic Batching to Cut Unnecessary Re-Renders
React 18's automatic batching merges state updates from timeouts, promises, and handlers into one render — but only with createRoot. Here's how to enable it, verify it with the Profiler, and handle the exceptions.
16 Dec 2025, 04:48 UTC

The problem: one click, three renders
In React 17 and earlier, state updates inside setTimeout callbacks, promise resolutions, and native event listeners each triggered their own render. A form that updates three pieces of state after an async fetch could render three times for a single logical change. React 18's automatic batching groups those updates into one render pass, and it works without changing your component code — but only if you opt in correctly and verify it actually applies to your update paths.
This guide covers enabling batching, confirming it works with the DevTools Profiler, and the cases where it does not apply.
Prerequisites
- React 18 or later (batching behavior described here assumes 18.x; verify against your installed version with
npm ls react). - Your app must be mounted with
createRoot. Apps still using the legacyReactDOM.renderAPI keep React 17 batching behavior even on React 18. - React DevTools browser extension installed for profiling.
Step 1: Switch to createRoot
In your entry file (typically src/index.js or src/main.jsx), replace the legacy mount:
// Before (React 17 behavior, no automatic batching)
// ReactDOM.render(<App />, document.getElementById('root'));
// After (React 18)
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(<App />);This is the single switch that enables automatic batching (along with the rest of the concurrent feature set). No component changes are required.
Step 2: Identify updates that now batch
Automatic batching applies to updates triggered from event handlers, timeouts, and promise callbacks. A typical async handler like this renders once in React 18 instead of three times:
function handleSubmit() {
fetchUser(id).then((user) => {
setUser(user); // update 1
setLoading(false); // update 2
setError(null); // update 3
// React 18: all three batch into a single render
});
}Because of this, the old unstable_batchedUpdates escape hatch from react-dom is unnecessary in function components and can be removed during migration.
Step 3: Verify with the Profiler
Do not assume batching is working — measure it:
- Open React DevTools, go to the Profiler tab, and click Record.
- Trigger the interaction that fires multiple state updates (e.g., submit the form above).
- Stop recording and count commits. One commit per logical interaction means batching is active; multiple commits for a single async callback means something is opting out.
For an automated check, a test can count renders directly:
let renders = 0;
function Counter() {
renders++;
const [a, setA] = React.useState(0);
const [b, setB] = React.useState(0);
return <button onClick={() => { setA(1); setB(2); }}>go</button>;
}After clicking, renders should increase by exactly one beyond the initial mount. Run this inside your existing test setup (Testing Library's fireEvent or userEvent works).
Where batching does not apply
- Class components: the improvement targets function components and hooks. Class components using
this.setStatedo not get the same benefit in all update paths; treat class-heavy codebases as a migration caveat, not a blocker. - Legacy root: as noted,
ReactDOM.renderdisables the new behavior entirely. - Intentional escape: if you genuinely need synchronous, separate renders (rare — e.g., reading layout between updates), wrap the update in
flushSyncfromreact-dom. Use sparingly; it forces a synchronous re-render and can hurt performance.
Pair batching with useTransition for heavy updates
Batching reduces render count, but a single render can still be expensive. For non-urgent updates (filtering a large list, rendering search results), mark them as transitions so urgent input stays responsive:
const [isPending, startTransition] = useTransition();
function onType(e) {
setQuery(e.target.value); // urgent: keep input snappy
startTransition(() => {
setFilteredResults(compute(e.target.value)); // non-urgent
});
}Batching and transitions solve different problems: batching merges updates, transitions deprioritizes them. Using both keeps interaction latency low in forms with frequent state changes.
Limitations
- Batching does not make individual renders cheaper; it only avoids redundant ones. Slow render logic still needs memoization or restructuring.
- Profiler results vary with component tree size; compare commit counts, not absolute timings, when validating.
- Behavior described assumes React 18.x. If you are on a newer major version, confirm against the release notes for your version.
Recovery
Switching to createRoot changes rendering semantics app-wide. If a regression appears, the rollback is reverting the entry file to ReactDOM.render — a one-line change. Because batching can surface latent bugs (code that accidentally relied on intermediate renders), roll back first and fix the assumption, rather than shipping flushSync patches everywhere.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.