Initializing Enclave...

React 18 Automatic Batching Not Working: How to Fix State Updates Still Triggering Multiple Re-Renders

Threat/Impact Level: HIGH | Downtime Risk: MEDIUM | Time to Fix: 10–20 mins


TL;DR

  • What broke: React 18 automatic batching requires createRoot. If you bootstrapped with legacy ReactDOM.render, batching inside async callbacks and native handlers is silently disabled — every setState call fires a separate render cycle.
  • How to fix it: Migrate your app entry point from ReactDOM.render() to ReactDOM.createRoot().render(). For third-party or native event systems, wrap with ReactDOM.flushSync only when you explicitly need synchronous flushing.
  • Shortcut: Use our Client-Side Sandbox below to auto-refactor this — paste your index.js or the offending component and get corrected code instantly.

The Incident (What Does the Error Mean?)

There is no thrown exception. That's what makes this insidious. Your symptom is a React DevTools Profiler showing N commits for N setState calls inside a single event handler or async block:

// DevTools Profiler flame graph shows:
Commit 1 — triggered by: setState (setLoading)
Commit 2 — triggered by: setState (setData)
Commit 3 — triggered by: setState (setError)
// Expected: 1 commit for all three

The immediate consequence: your component renders 3× per user interaction. In a component tree with expensive children, this is a latency cliff. In components with side effects tied to render (analytics, scroll position, focus management), this causes observable UI glitches and race conditions.


The Attack Vector / Blast Radius

This is a silent performance regression that survives code review because the app appears functionally correct. The blast radius:

  • Cascading child re-renders: Every unnecessary parent commit propagates down the tree unless children are memoized with React.memo — and most aren't.
  • State tearing in concurrent features: If you're using useTransition or useDeferredValue, unbatched updates can cause the UI to render intermediate inconsistent states — a loading spinner appears and disappears within the same logical operation.
  • Event handler thrash in data-heavy UIs: Tables, dashboards, and forms with 10+ state slices can hit 30–50 extra renders per second under normal user interaction.
  • Root cause is invisible at runtime: No warning, no error boundary trigger, no console output. You find this only in the Profiler or when a user reports jank.

The specific failure modes by context:

Context React 17 Behavior React 18 (createRoot) React 18 (legacy render)
React synthetic event handler Batched Batched Batched
setTimeout / setInterval NOT batched Batched ✅ NOT batched ❌
fetch().then() / async/await NOT batched Batched ✅ NOT batched ❌
Native DOM addEventListener NOT batched Batched ✅ NOT batched ❌

How to Fix It (The Solution)

Basic Fix — Migrate to createRoot

This is the only correct fix. Everything else is a workaround.

// src/index.js
- import ReactDOM from 'react-dom';
+ import ReactDOM from 'react-dom/client';
  import App from './App';

- ReactDOM.render(
-   <React.StrictMode>
-     <App />
-   </React.StrictMode>,
-   document.getElementById('root')
- );

+ const root = ReactDOM.createRoot(document.getElementById('root'));
+ root.render(
+   <React.StrictMode>
+     <App />
+   </React.StrictMode>
+ );

That single change enables automatic batching everywhere — setTimeout, Promises, native events — with zero additional code.


Enterprise Best Practice — Opt-Out Selectively with flushSync

After migrating to createRoot, there are legitimate cases where you need a synchronous, unbatched update — e.g., forcing a DOM measurement immediately after a state change, or integrating with a non-React animation library.

- import { useState } from 'react';
+ import { useState } from 'react';
+ import { flushSync } from 'react-dom';

  function SearchBar() {
    const [query, setQuery] = useState('');
    const [results, setResults] = useState([]);

    async function handleSearch(e) {
-     setQuery(e.target.value);      // render 1
-     setResults(await fetch(...));  // render 2
      // Problem: even with createRoot, the await boundary
      // splits these into two commits if you need synchronous DOM
      // access between them.

+     // Batch the pre-fetch state update synchronously when you
+     // need an immediate DOM read (e.g., input height for animation)
+     flushSync(() => {
+       setQuery(e.target.value); // forced synchronous commit
+     });
+     // DOM is now updated — safe to measure
+     const height = inputRef.current.offsetHeight;
+     const data = await fetchResults(e.target.value);
+     setResults(data); // this will batch with any concurrent updates
    }
  }

Rule of thumb: flushSync is an escape hatch, not a pattern. If you're calling it more than once per component, your state architecture needs review.


💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your component trees, API endpoint strings, and internal state shapes. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing index.js or component into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

Automatic batching failures are entirely preventable at the lint and test layer. Ship these guardrails:

1. ESLint — ban legacy ReactDOM.render

Add to your .eslintrc:

{
  "rules": {
    "no-restricted-imports": ["error", {
      "paths": [{
        "name": "react-dom",
        "importNames": ["render"],
        "message": "Use ReactDOM.createRoot() from 'react-dom/client'. Legacy render() disables React 18 automatic batching."
      }]
    }]
  }
}

2. React Testing Library — assert commit count in critical paths

import { act, render, screen } from '@testing-library/react';

test('data fetch triggers exactly one re-render', async () => {
  const renderSpy = jest.fn();
  // Wrap component to count renders
  render(<TrackedComponent onRender={renderSpy} />);
  await act(async () => {
    screen.getByRole('button', { name: /load/i }).click();
  });
  // With createRoot + batching, async handler = 1 commit
  expect(renderSpy).toHaveBeenCalledTimes(2); // mount + 1 batched update
});

3. Profiler CI assertion (advanced)

Integrate <React.Profiler> in your test harness and fail the build if onRender callback fires more than a defined threshold per user interaction. This catches batching regressions before they hit production.

4. Dependency audit in your pipeline

# Fail CI if react-dom version < 18 or if legacy render API is detected in bundle
grep -r "ReactDOM.render(" src/ && echo "LEGACY RENDER DETECTED — FAILING BUILD" && exit 1

Add this as a pre-commit hook or a CI step before your build stage.

Related Diagnostics

"Part of the Performance Utility Matrix."

View all 219 Performance Tools →