React 18 Automatic Batching: When It Silently Breaks
React 18 batches all state updates automatically—unless you're using legacy render, flushSync, or hitting async boundaries. Know when batching breaks.
React 18 automatically batches all state updates—setTimeout, promises, native events, everything.
Except when it doesn't. Understanding when batching breaks helps you avoid mysterious double renders
and know when flushSync is your only escape hatch.
The Promise of Automatic Batching
In React 17 and earlier, batching only worked inside React event handlers:
// React 17: Only batches in React events
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// ✅ One render (batched)
}
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// ❌ Two renders (not batched)
}, 1000);
React 18 with createRoot batches everywhere—timeouts, promises, native events,
queueMicrotask. This is automatic batching.
// React 18 with createRoot: Batches everywhere
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// ✅ One render (batched)
}, 1000);
fetch('/api/data').then(() => {
setLoading(false);
setData(result);
// ✅ One render (batched)
});Edge Case 1: Still Using ReactDOM.render
The biggest gotcha: automatic batching only works with createRoot. If you're
still using the legacy ReactDOM.render, you get React 17 behavior:
// ❌ Legacy API - NO automatic batching outside React events
import { render } from 'react-dom';
render(<App />, document.getElementById('root'));
// ✅ Concurrent API - automatic batching everywhere
import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')).render(<App />);Check your entry point. Many codebases upgraded to React 18 but never switched the render call. You're running React 18 with React 17 behavior.
Edge Case 2: flushSync Breaks Batching
Sometimes you need synchronous DOM updates—measuring elements, focusing inputs,
coordinating with third-party libraries. flushSync forces immediate render:
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCount(c => c + 1);
});
// DOM is updated NOW
flushSync(() => {
setFlag(f => !f);
});
// DOM updated again
// Two separate renders, not batched
}
Use sparingly. flushSync bypasses React's scheduling optimizations and can
cause performance issues if overused.
When flushSync Makes Sense
// Measuring after state change
function handleExpand() {
flushSync(() => {
setExpanded(true);
});
// Now we can measure the expanded element
const height = ref.current.getBoundingClientRect().height;
setHeight(height);
}
// Focus management
function handleSubmit() {
flushSync(() => {
setError('Invalid input');
});
// Error message is in DOM, now focus it
errorRef.current.focus();
}Edge Case 3: Separate Async Contexts
Updates in different async contexts can't be batched together:
async function handleClick() {
setA(1); // Batch 1 starts
setB(2); // Same batch
await fetch('/api/data'); // ⚠️ Async boundary
setC(3); // Batch 2 (new context)
setD(4); // Same batch as C
}
// Result: 2 renders total
Each await creates a new "async context". Updates before and after can't batch
together because React doesn't know they're related.
Edge Case 4: External Store Updates
Updates from external stores (Redux, Zustand, vanilla JS) aren't automatically batched with React state:
// Redux dispatch + React setState
function handleClick() {
dispatch(incrementCounter()); // External store
setLocalFlag(true); // React state
// May or may not batch—depends on implementation
}
Most modern state managers handle this with useSyncExternalStore, but legacy
integrations might cause extra renders. Check your state library's React 18 compatibility notes.
Edge Case 5: Class Components with unstable_batchedUpdates
Pre-React 18 code sometimes used the unstable API explicitly:
import { unstable_batchedUpdates } from 'react-dom';
// Old pattern (React 17)
setTimeout(() => {
unstable_batchedUpdates(() => {
setA(1);
setB(2);
});
}, 1000);
With React 18 and createRoot, this is now unnecessary—batching happens automatically.
The API still works (it's a no-op wrapper now), but you can safely remove it.
Debugging Batching Issues
Add a render counter to verify batching behavior:
function Component() {
const renderCount = useRef(0);
renderCount.current++;
useEffect(() => {
console.log('Render #', renderCount.current);
});
function handleClick() {
setA(1);
setB(2);
setC(3);
// Should log ONE render, not three
}
return <button onClick={handleClick}>Test</button>;
}If you see multiple renders where you expect one, check:
- Are you using
createRoot? - Is there a
flushSynccall? - Are updates split across
awaitboundaries? - Is an external store involved?
Migration Checklist
- Switch to createRoot—this is the prerequisite for automatic batching
- Remove unstable_batchedUpdates—no longer needed
- Audit flushSync usage—only use when you genuinely need sync DOM updates
- Test external store integrations—ensure they work correctly with React 18
- Don't assume batching—for critical flows, verify with render counting
Key Takeaways
- Automatic batching requires
createRoot—legacyrenderdoesn't batch outside events flushSyncintentionally breaks batching for synchronous DOM accessawaitboundaries create separate batching contexts- External stores may not batch with React state—check your library's docs
- When in doubt, add a render counter to verify behavior
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement