Back to React
React
easy
mid

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.

6 min read·~5 min to think through

One-line migration with significant implications.

The change

tsx
// 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

tsx
// React 17
ReactDOM.hydrate(<App />, container);

// React 18
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(container, <App />);

What createRoot unlocks

  1. Concurrent rendering — React can pause, abandon, or replay renders based on priority.
  2. Automatic batching everywhere — multiple setState calls in promises/timeouts collapse into one render (previously only event handlers batched).
  3. Suspense for data fetching — streaming, partial reveals.
  4. useTransition / useDeferredValue — priority-based scheduling.
  5. Streaming SSR — renderToPipeableStream.
  6. 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:

tsx
// 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

  1. Update import: import { createRoot } from 'react-dom/client'.
  2. Replace ReactDOM.render(<App/>, root) with createRoot(root).render(<App/>).
  3. Same for hydratehydrateRoot.
  4. Audit useEffect cleanup — StrictMode dev double-mount will surface bugs.
  5. TypeScript: children prop 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.

Senior engineer discussion

Senior framing: createRoot is the wire that connects your app to React's concurrent scheduler. The API is intentionally minimal — most apps never call .render() again after init. The behavioral switch (batching, scheduling, StrictMode) is the substantive change.

Related questions