Initializing Enclave...

How to Fix 'ReactDOM.render is no longer supported in React 18' and Migrate to createRoot

Threat/Impact Level: HIGH | Downtime Risk: HIGH | Time to Fix: 5–15 mins


TL;DR

  • What broke: ReactDOM.render() was fully removed in React 18. Calling it throws a console error, disables all concurrent features (Suspense, transitions, automatic batching), and React silently falls back to legacy mode — or crashes entirely in strict builds.
  • How to fix it: Replace ReactDOM.render(<App />, container) with ReactDOM.createRoot(container).render(<App />) and update your import to react-dom/client.
  • Fast path: Use our Client-Side Sandbox above to auto-refactor this — paste your index.js or main.tsx and get a corrected diff in seconds.

The Incident (What Does the Error Mean?)

Raw console error thrown by React 18:

Warning: ReactDOM.render is no longer supported in React 18.
Use createRoot instead. Until you switch to the new API,
your app will behave as if it's running React 17.

Learn more: https://reactjs.org/link/switch-to-createroot
    at App

In production builds with [email protected], calling ReactDOM.render() does not silently degrade — it is a hard API removal. React 18 ships two separate entry points: react-dom (legacy, for backward compat shim only) and react-dom/client (the actual 18 runtime). Any code path that never calls createRoot is permanently locked out of:

  • Concurrent rendering engine
  • useTransition / useDeferredValue
  • Automatic batching across async boundaries
  • Streaming SSR with renderToPipeableStream
  • React Server Components readiness

If your package.json says "react": "^18.x" but your index.js still calls ReactDOM.render, you are running a Frankenstein hybrid — React 18 binaries with a React 17 bootstrap. Every benchmark, every profiler trace, every Suspense boundary is lying to you.


The Blast Radius

This is not a cosmetic warning. The consequences stack:

1. Silent concurrent mode lockout. The entire concurrent scheduler is gated behind createRoot. Your <Suspense> boundaries will appear to work but are running the legacy synchronous fallback path. You will not see the perf gains you upgraded for.

2. StrictMode double-invoke breaks. React 18 StrictMode under createRoot intentionally double-invokes effects to surface impure components. Under legacy ReactDOM.render, this behavior is inconsistent — bugs that would surface in staging are hidden until production.

3. Third-party library breakage. Libraries like React Query v5, Recoil, Jotai, and Zustand 4.x ship with peer dependency assumptions around concurrent mode semantics. Calling them from a legacy-mode root produces state tearing — values rendered mid-flight become inconsistent across the tree.

4. SSR hydration mismatch cascade. If you use ReactDOM.hydrate() (also removed), the server-rendered HTML will be fully discarded and client-side re-rendered from scratch on every page load. Your Core Web Vitals (LCP, CLS) crater. Lighthouse scores drop. SEO impact is measurable within days.

5. Future React versions will hard-throw. The current behavior is a shim. React 19 removes the shim. You are accumulating migration debt that will become a P0 incident.


How to Fix It

Basic Fix — index.js / main.tsx

- import React from 'react';
- import ReactDOM from 'react-dom';
+ import React from 'react';
+ import ReactDOM from 'react-dom/client';
  import App from './App';

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

SSR Hydration Fix — hydrateRoot

If you are hydrating a server-rendered shell (Next.js custom _app, Remix entry, or manual SSR):

- import ReactDOM from 'react-dom';
+ import { hydrateRoot } from 'react-dom/client';
  import App from './App';

- ReactDOM.hydrate(<App />, document.getElementById('root'));
+ hydrateRoot(document.getElementById('root'), <App />);

Enterprise Best Practice — TypeScript + Null Guard + Error Boundary

In production apps, the root container lookup can fail silently if your HTML template changes. Harden the bootstrap:

- import ReactDOM from 'react-dom';
+ import { createRoot } from 'react-dom/client';
  import App from './App';
+ import { RootErrorBoundary } from './components/RootErrorBoundary';

- ReactDOM.render(<App />, document.getElementById('root'));
+ const container = document.getElementById('root');
+
+ if (!container) {
+   throw new Error(
+     '[Bootstrap] Root container #root not found in DOM. Check public/index.html.'
+   );
+ }
+
+ const root = createRoot(container);
+ root.render(
+   <React.StrictMode>
+     <RootErrorBoundary>
+       <App />
+     </RootErrorBoundary>
+   </React.StrictMode>
+ );

Key discipline: Store the root reference if you need to call root.unmount() in test teardown or micro-frontend shell unload sequences. Calling createRoot on the same container twice throws.


💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config 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

This class of error should never reach a pull request. Enforce it at the toolchain layer:

1. ESLint — eslint-plugin-react deprecated API rule

Add to .eslintrc.json:

{
  "rules": {
    "react/no-deprecated": "error"
  }
}

This flags ReactDOM.render, ReactDOM.hydrate, React.createFactory, and other removed APIs at lint time — before git push.

2. react-dom Import Path Enforcement via ESLint no-restricted-imports

{
  "rules": {
    "no-restricted-imports": [
      "error",
      {
        "paths": [
          {
            "name": "react-dom",
            "importNames": ["render", "hydrate", "unmountComponentAtNode"],
            "message": "Legacy ReactDOM APIs removed in React 18. Use 'react-dom/client' createRoot/hydrateRoot instead."
          }
        ]
      }
    ]
  }
}

3. Dependency Version Pinning in CI

In your GitHub Actions / GitLab CI pipeline, add a step that hard-fails if react-dom version does not match react:

- name: Validate React peer dependency alignment
  run: |
    REACT_VER=$(node -p "require('./node_modules/react/package.json').version")
    REACTDOM_VER=$(node -p "require('./node_modules/react-dom/package.json').version")
    if [ "$REACT_VER" != "$REACTDOM_VER" ]; then
      echo "FATAL: react@$REACT_VER and react-dom@$REACTDOM_VER version mismatch."
      exit 1
    fi

4. Automated Codemod for Large Codebases

For monorepos with dozens of entry points, run the official codemod rather than manual edits:

npx codemod@latest react/19/replace-reactdom-render
# or for React 18 specifically:
npx react-codemod replace-ReactDOM-render

Run this in your CI migration branch, review the diff, then lock the ESLint rule to prevent regression.

Related Diagnostics

"Part of the Syntax Utility Matrix."

View all 153 Syntax Tools →