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)withReactDOM.createRoot(container).render(<App />)and update your import toreact-dom/client. - Fast path: Use our Client-Side Sandbox above to auto-refactor this — paste your
index.jsormain.tsxand 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.