What is the difference between ReactDOM.render and createRoot in React 18?
`ReactDOM.render(<App/>, root)` is the legacy synchronous API (React 16/17). `createRoot(root).render(<App/>)` is the React 18 API that enables concurrent features: automatic batching everywhere, useTransition, useDeferredValue, streaming SSR, Suspense for data. Functionally similar for simple apps but createRoot unlocks the new scheduler. ReactDOM.render still works in 18 but logs a deprecation warning.
One-line migration with significant implications.
The change
// React 16 / 17
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));
// React 18
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root')!);
root.render(<App />);Hydration equivalent
// React 17
ReactDOM.hydrate(<App />, container);
// React 18
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(container, <App />);What createRoot unlocks
- Concurrent rendering — React can pause, abandon, or replay renders based on priority.
- Automatic batching everywhere — multiple setState calls in promises/timeouts collapse into one render (previously only event handlers batched).
- Suspense for data fetching — streaming, partial reveals.
- useTransition / useDeferredValue — priority-based scheduling.
- Streaming SSR — renderToPipeableStream.
- Selective hydration — components hydrate based on user interaction.
ReactDOM.render still works in 18 but operates in 'legacy mode' — none of the above features are active.
Behavior differences
Automatic batching:
// React 17 with ReactDOM.render
fetch('/api').then(() => {
setA(1);
setB(2);
// → 2 renders
});
// React 18 with createRoot
fetch('/api').then(() => {
setA(1);
setB(2);
// → 1 render
});StrictMode dev double-mount:
createRoot + StrictMode dev mounts → unmounts → mounts each component once. ReactDOM.render in 17 doesn't do this.
Suspense:
Old API: Suspense only worked with React.lazy. New API: Suspense works for data fetching with frameworks.
Migration steps
- Update import:
import { createRoot } from 'react-dom/client'. - Replace
ReactDOM.render(<App/>, root)withcreateRoot(root).render(<App/>). - Same for
hydrate→hydrateRoot. - Audit useEffect cleanup — StrictMode dev double-mount will surface bugs.
- TypeScript:
childrenprop is no longer implicit on FC; type it explicitly.
Things that can break in migration
- Effects that aren't idempotent (subscribe twice on dev double-mount).
- Code that relied on un-batched updates (e.g. reading DOM between two setStates in a promise).
- Third-party stores that aren't
useSyncExternalStore-safe under concurrent.
Why two APIs
React 18 kept ReactDOM.render for an easy migration. Most apps benefit from createRoot immediately. The legacy path remains for codebases not ready to adopt concurrent features.
Senior framing
createRoot is the gateway to React 18's concurrent features. The API change is trivial; the behavioral implications (batching, scheduling, StrictMode behavior, Suspense capabilities) matter. Most production codebases moved within 2022.
Follow-up questions
- •What's automatic batching and why does it matter?
- •What does StrictMode dev double-mount actually catch?
- •Does Suspense behave differently under createRoot vs ReactDOM.render?
Common mistakes
- •Mixing both APIs in the same app — leads to mysterious behavior differences.
- •Forgetting to audit useEffect cleanup before enabling StrictMode 18 dev mode.
- •Assuming createRoot makes renders 'faster' — it enables concurrent features but doesn't speed up individual renders.
Performance considerations
- •createRoot doesn't make individual renders faster. It enables prioritization so urgent updates (clicks, input) don't get stuck behind expensive renders. The performance win is felt by the user, not measured per-render.
Edge cases
- •Third-party libraries that don't use useSyncExternalStore can tear under concurrent.
- •createRoot persists root state; calling .render() multiple times reuses the same root.
- •.unmount() exists on the root instance for cleanup.
Real-world examples
- •Every Next.js 13+ app uses createRoot under the hood. Most production apps migrated to React 18 by end of 2022. Some old codebases stayed on ReactDOM.render for years due to third-party compat issues.