react-dom
react-dom is React's renderer for the browser DOM: createRoot and hydrateRoot mount component trees, plus portals, flushSync, form-status hooks, resource preloading, and the server rendering entry points under react-dom/server and react-dom/static. React itself (the react package) is renderer-agnostic; react-dom is the half that actually touches document. The 1.4KB bundlephobia number is only the package root shim; the real renderer you ship comes in through react-dom/client and is an order of magnitude larger.
The default web renderer for the dominant UI library; if you chose React you have chosen react-dom, and it is excellent at its job. The honest caveat is that you rarely interact with it directly anymore beyond createRoot, portals, and the form hooks, and if bundle size matters more than ecosystem, preact covers most apps.
Use it if
- You are building for the browser with React: this is not optional, react without react-dom renders nothing on the web
- You need an escape hatch from a framework: portals for modals and tooltips, flushSync for the rare must-be-synchronous DOM read after an update
- You do custom SSR without a framework and need renderToPipeableStream or the static prerender APIs directly
- You want React 19 form features: useFormStatus and form actions live in react-dom, not react
- Bundle size is the constraint: preact with preact/compat implements most of the React API in roughly a tenth of the payload
- You are not rendering to a browser DOM: React Native, ink, and react-three-fiber replace react-dom with their own renderers
- Your site is mostly static content: shipping a client-side renderer to hydrate text is the thing frameworks like Astro exist to avoid
- You are on legacy ReactDOM.render code and cannot migrate: React 19 removed render, hydrate, findDOMNode, and unmountComponentAtNode outright, so the upgrade is work, not a version bump
Setup reality
npm install react react-dom, and keep the two versions exactly in sync: a mismatch (or two copies from a hoisting accident) produces the famously unhelpful 'invalid hook call' error. React 19 removed the long-deprecated APIs, so older codebases must move to createRoot before upgrading, and libraries that still call findDOMNode simply break. You also need a build step for JSX, but in 2026 every bundler template handles that; the real setup decisions have moved up a level into frameworks like Next.js and React Router, where you rarely import react-dom yourself except for portals.
Patterns
Mount an app with createRootmount-app
import { createRoot } from 'react-dom/client'
import App from './App'
const root = createRoot(document.getElementById('root'))
root.render(<App />)The only mount API since React 19: ReactDOM.render was removed after being deprecated through 18. Note the import is 'react-dom/client', not 'react-dom'.
Hydrate server-rendered HTMLhydrate-ssr
import { hydrateRoot } from 'react-dom/client'
import App from './App'
hydrateRoot(document.getElementById('root'), <App />)The client tree must render the same output as the server HTML or React logs hydration mismatches and re-renders client-side; anything time- or random-dependent needs useEffect or suppressHydrationWarning.
Render a modal through a portalportal-modal
import { createPortal } from 'react-dom'
function Modal({ children }) {
return createPortal(
<div className="overlay">{children}</div>,
document.body,
)
}The DOM node moves but the React tree does not: context, state, and event bubbling still follow the component hierarchy, which is exactly what you want for modals inside providers.
Force a synchronous DOM updateflush-sync
import { flushSync } from 'react-dom'
flushSync(() => {
setItems((prev) => [...prev, newItem])
})
// DOM is updated here, safe to measure
listRef.current.lastElementChild.scrollIntoView()Opts out of React 18+ batching so you can read layout right after a state change. It hurts performance and the docs call it a last resort; scrolling-to-new-content is the one legitimate everyday use.
Pending state with useFormStatusform-status
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>
}
// <form action={saveAction}><SubmitButton /></form>React 19 API. It only reports the status of a parent form element, so the hook must be called from a component rendered inside the form, not from the component that renders the form itself.
Preload resources imperativelypreload-resources
import { preload, preconnect, preinit } from 'react-dom'
function Card() {
preconnect('https://cdn.example.com')
preload('/fonts/inter.woff2', { as: 'font', crossOrigin: 'anonymous' })
preinit('/analytics.js', { as: 'script' })
return <article>...</article>
}React 19 resource APIs: calls are deduplicated and during SSR they emit <link> tags in the head. preload hints a fetch, preinit actually executes/inserts the resource.
Stream SSR with renderToPipeableStreamssr-streaming
import { renderToPipeableStream } from 'react-dom/server'
app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
res.setHeader('content-type', 'text/html')
pipe(res)
},
})
})Streams the shell immediately and flushes Suspense boundaries as they resolve. This is the Node entry point; edge runtimes use renderToReadableStream from the same package.
Prerender fully static HTMLstatic-prerender
import { prerenderToNodeStream } from 'react-dom/static'
const { prelude } = await prerenderToNodeStream(<App />, {
bootstrapScripts: ['/main.js'],
})
prelude.pipe(fileStream)Unlike renderToPipeableStream, the static APIs wait for all data and Suspense to resolve and produce complete HTML, which is what you want for build-time static generation.
Catch rendering errors at the rootroot-error-handlers
const root = createRoot(container, {
onUncaughtError: (error, info) => {
reportError(error, info.componentStack)
},
onCaughtError: (error) => {
// errors already handled by an ErrorBoundary
},
})React 19 root options that replace patching console.error for error reporting; onRecoverableError also exists for hydration-mismatch recovery.
Migrate removed React 18-era APIslegacy-migration
// removed in React 19:
// ReactDOM.render(<App />, el)
// ReactDOM.unmountComponentAtNode(el)
import { createRoot } from 'react-dom/client'
const root = createRoot(el)
root.render(<App />)
root.unmount() // replaces unmountComponentAtNodefindDOMNode is also gone with no direct replacement: use a ref on the element instead. Libraries last published before 2023 are the usual source of these crashes after upgrading.
Keep react and react-dom in lockstepversion-lock
{
"dependencies": {
"react": "19.2.8",
"react-dom": "19.2.8"
}
}
// debugging a mismatch:
// npm ls react react-domVersion skew or duplicated copies cause 'invalid hook call' and 'cannot read properties of null' errors deep in hooks. npm ls showing two react versions is the diagnosis 90% of the time.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| preact | npm | You want the React programming model at a fraction of the bundle size and can live with compat-layer edge cases. |
| solid-js | npm | You want JSX with fine-grained reactivity and no virtual DOM, trading the React ecosystem for faster updates. |
| svelte | npm | You prefer compiling components to minimal imperative DOM code over shipping a runtime renderer at all. |