EdgeCases Logo
Feb 2026
React
Surface
5 min read

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
react-18
batching
performance
flushsync
edge-case

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 flushSync call?
  • Are updates split across await boundaries?
  • 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—legacy render doesn't batch outside events
  • flushSync intentionally breaks batching for synchronous DOM access
  • await boundaries 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

Related Insights

Explore related edge cases and patterns

CSS
Surface
CSS contain: Render Isolation for Performance
6 min
Browser APIs
Deep
ResizeObserver: The 'Loop Limit Exceeded' Error
6 min
React
Deep
useEffectEvent: Solving Stale Closures Forever
5 min

Advertisement