cheat sheet
react-dom
Package-level reference for react-dom — client/server rendering, hydration, portals, flushSync, and the React 19 root API.
react-dom
What it is
react-dom is the package that actually paints React trees onto a browser DOM (or streams HTML on the server). It is the companion runtime to react itself — the core React package describes elements and hooks; react-dom decides how those become real <div>s, <input>s, and <button>s. Native platforms use different renderers (react-native, react-three-fiber, ink).
Reach for react-dom automatically whenever you build a web app with React — every framework (Next.js, Remix, Vite + React, Astro islands) depends on it. The package itself becomes interesting when you need direct rendering control: portals, hydration, server streaming, manual flushSync, or a custom mount point inside a non-React shell.
Install
react-dom is always paired with react at the exact same version.
npm install react react-dom
Output: added react and react-dom to dependencies
pnpm add react react-dom
Output: added 2 packages
yarn add react react-dom
Output: added react and react-dom
bun add react react-dom
Output: installed react and react-dom
npm install -D @types/react @types/react-dom
Output: added @types/react and @types/react-dom to devDependencies
Versioning & Node support
Current line is react-dom@19.x — version-locked to react@19.x.
- Works in any modern browser (Chrome 64+, Safari 12+, Firefox 67+ are typical floors for the production build).
react-dom/serverruns in Node 18.18+ / 20+ for streaming server rendering. Edge runtimes usereact-dom/server.edge.- Dual ESM/CJS.
- Versioning: always pin
react-domto the same minor/patch asreact. A mismatch surfaces as runtime "Invalid hook call" errors. - TypeScript types in
@types/react-dom— versioned independently of@types/reactbut should track the same React major.
Package metadata
- Maintainer: Meta Open Source + React core team
- Project home: github.com/facebook/react
- Docs: react.dev/reference/react-dom
- npm: npmjs.com/package/react-dom
- License: MIT
- First released: 2015 (split out of
reactin 0.14) - Downloads: tens of millions weekly — tracks
react's install count
Peer dependencies & extras
react-dom declares react as a peer dependency. The relationship is one-way: every React DOM app needs both packages, and they must match versions.
Sub-entries to know:
react-dom/client—createRoot,hydrateRoot. The client renderer used in 18+.react-dom/server—renderToString,renderToPipeableStream,renderToReadableStreamfor SSR.react-dom/server.edge— edge-runtime-safe SSR (Cloudflare Workers, Vercel Edge).react-dom/server.node— Node-only streaming.react-dom/test-utils— deprecated; use@testing-library/react.
Common companions:
react-error-boundary— error-boundary component@testing-library/react— DOM-based component testingframer-motion,@react-spring/web— animationreact-aria/radix-ui— accessible primitives
Alternatives
| Package | Trade-off |
|---|---|
preact/compat | API-compatible swap-in renderer, ~3 KB. Tiny apps and widgets. |
solid-js (with solid-js/web) | Different framework; fine-grained reactivity. |
lit-html | Web-components-based rendering. Standards-aligned. |
vanilla-jsx / jsx-dom | JSX with no virtual DOM at all. Niche. |
Custom renderer via react-reconciler | Build your own (e.g. for canvas, terminal). Power-user only. |
Real-world recipes
Client-side createRoot (React 18+ / 19)
The 16-style ReactDOM.render is gone. Use createRoot once and call root.render whenever you need to swap trees.
import { createRoot } from "react-dom/client";
import App from "./App";
const container = document.getElementById("app")!;
const root = createRoot(container);
root.render(<App />);
Output: mounts the app; concurrent rendering is on by default — interactions stay responsive during large updates.
hydrateRoot for SSR pages
If the HTML was produced by renderToPipeableStream on the server, the client hydrates instead of re-rendering.
import { hydrateRoot } from "react-dom/client";
import App from "./App";
hydrateRoot(document.getElementById("app")!, <App />);
Output: attaches event listeners to existing DOM; throws a hydration mismatch error if server and client trees diverge.
Portal for modal outside the DOM tree
createPortal renders into a different DOM node while keeping React event bubbling.
import { createPortal } from "react-dom";
export function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
return createPortal(
<div className="backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>{children}</div>
</div>,
document.body,
);
}
Output: modal renders as a direct child of <body> but still receives parent context, theme, and React events.
flushSync for forcing a synchronous render
React 18+ batches updates. When a layout measurement or a third-party library expects DOM-after-update, force a flush.
import { flushSync } from "react-dom";
import { useState } from "react";
export function ScrollOnAdd() {
const [items, setItems] = useState<string[]>([]);
const ref = useRef<HTMLUListElement>(null);
function addAndScroll(item: string) {
flushSync(() => setItems((prev) => [...prev, item]));
ref.current?.scrollTo({ top: ref.current.scrollHeight });
}
return <ul ref={ref}>{items.map((i) => <li key={i}>{i}</li>)}</ul>;
}
Output: DOM updates synchronously inside flushSync, so the scrollTo call sees the new content.
Server streaming with renderToPipeableStream
For Node-based SSR (Express, Fastify, raw http).
import { renderToPipeableStream } from "react-dom/server";
import App from "./App";
app.get("/*", (req, res) => {
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ["/client.js"],
onShellReady() {
res.setHeader("Content-Type", "text/html");
pipe(res);
},
onError(err) {
console.error(err);
res.status(500);
},
});
});
Output: streams HTML as soon as the shell is ready; Suspense boundaries flush their content as data resolves.
Edge SSR with renderToReadableStream
For Cloudflare Workers / Vercel Edge.
import { renderToReadableStream } from "react-dom/server";
export default {
async fetch() {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ["/client.js"],
});
return new Response(stream, { headers: { "Content-Type": "text/html" } });
},
};
Output: returns a Response with a streaming body — works in any Web-standards runtime.
preload and preinit for resource hints
React 19 exposes resource-hint APIs that emit <link rel="preload"> / <link rel="preconnect"> from anywhere in the tree.
import { preload, preinit } from "react-dom";
function Header() {
preinit("/styles/critical.css", { as: "style" });
preload("/img/hero.webp", { as: "image" });
return <h1>Welcome</h1>;
}
Output: React deduplicates and injects appropriate <link> tags into the document head.
Production deployment
react-dom ships dev and prod builds. The dev build includes warnings, propType checks, and verbose errors; the prod build strips them.
NODE_ENV=productionfor the bundler. Vite / Next.js / Webpack handle this automatically withnpm run build. Without it your bundle is 3× larger and slower.@vitejs/plugin-reactor@vitejs/plugin-react-swc. SWC is faster on cold builds; classic is more mature. Pick one.- Server bundle vs client bundle. Frameworks separate them. Don't import
react-dom/serverinto client code — it inflates the bundle by ~50 KB. react-dom/server.edgefor Workers / Edge. The defaultreact-dom/serverimports Node built-ins that fail on edge runtimes.
Performance tuning
- Use
createRootonce. Re-creating roots is expensive. Mount once, callroot.renderto swap. - Streaming SSR is faster than
renderToString. Stream the shell, suspend on data, flush content as it resolves. - Selective hydration. React 18+ hydrates interactive parts of the page even while other parts are still loading. Works automatically with Suspense.
- Avoid
flushSyncunless needed. Synchronous flushes block the main thread and undo concurrent rendering's benefits. useDeferredValueover manualflushSyncfor "render this stale version while computing the fresh one" — keeps the renderer free.- Hydration mismatches are expensive. Even a single mismatch invalidates the entire tree from that node up. Fix dates, locales, and random IDs that differ server vs client.
<Suspense>boundaries trade latency for granularity. Too few → coarse fallback; too many → overhead. Profile and adjust.
Version migration guide
react-dom versions move in lockstep with react.
| From | To | Key changes |
|---|---|---|
react-dom@17 | react-dom@18 | createRoot replaces ReactDOM.render; hydrateRoot replaces hydrate. renderToPipeableStream added. Strict mode double-invokes effects in dev. |
react-dom@18 | react-dom@19 | New resource-hint APIs (preload, preinit, prefetchDNS). Improved hydration error messages. Refs as props (paired with React 19 core). |
18 → 19 migration steps:
Before (18):
import { useEffect } from "react";
function Page() {
useEffect(() => {
const link = document.createElement("link");
link.rel = "preload";
link.href = "/img.webp";
link.as = "image";
document.head.appendChild(link);
}, []);
return <main>…</main>;
}
After (19):
import { preload } from "react-dom";
function Page() {
preload("/img.webp", { as: "image" });
return <main>…</main>;
}
Output: declarative resource hints; React handles dedup and head injection.
Checklist:
- Upgrade
reactandreact-domtogether. Useoverrides/resolutionsto dedupe. - Upgrade
@types/react-dom. - Replace
ReactDOM.render/ReactDOM.hydratecalls (these were already gone in 18; old codebases occasionally still have them). - Audit
findDOMNode(deprecated since 18; refs are the replacement). - Migrate
unstable_batchedUpdatescalls — automatic batching makes them unnecessary.
Security considerations
dangerouslySetInnerHTMLis XSS. React makes it verbose deliberately. Always sanitise untrusted HTML withDOMPurify.- Hydration mismatches as a side-channel. A server-rendered "logged in as Alice" that client-hydrates as "logged out" suggests auth state is exposed in HTML. Treat mismatches as security signals.
href={userInput}. Ajavascript:URL in a link is XSS. React 19+ disallowsjavascript:URLs by default at the renderer level — verify your version.src={userInput}. Image and iframe sources are equally vulnerable. Allowlist origins.- Server-side resource hints.
preload(userControlledUrl)leaks the URL into HTML. Validate. - Strict CSP. React injects inline styles for
style={...}. Either use a nonce-based CSP or move styles to CSS classes.
Testing & CI integration
@testing-library/react setup
// vitest.setup.ts
import "@testing-library/jest-dom/vitest";
// Button.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { Button } from "./Button";
describe("Button", () => {
it("renders children and handles clicks", () => {
const fn = vi.fn();
render(<Button onClick={fn}>Click me</Button>);
fireEvent.click(screen.getByRole("button"));
expect(fn).toHaveBeenCalled();
});
});
Output: test passes; the jsdom environment provides a DOM for react-dom to render into.
Snapshot test
import { renderToString } from "react-dom/server";
import { expect, it } from "vitest";
it("renders heading", () => {
const html = renderToString(<h1>Welcome</h1>);
expect(html).toMatchInlineSnapshot('"<h1>Welcome</h1>"');
});
Output: asserts on the static HTML string.
Ecosystem integrations
| Package | Role |
|---|---|
next | App-router + server components; hides react-dom behind its own runtime |
vite + @vitejs/plugin-react | SPA build setup |
astro + @astrojs/react | React islands inside Astro pages |
@testing-library/react | DOM-based unit testing |
react-error-boundary | Error-boundary component |
framer-motion | Animations using react-dom's reconciler |
react-aria | Accessible component primitives |
Troubleshooting common errors
Invalid hook call. Hooks can only be called inside the body of a function component. — usually two copies of React in the tree. Run npm ls react react-dom; dedupe with overrides.
Hydration failed because the server rendered HTML didn't match the client. — locale, timezone, or random IDs differ between server and client. Pin them, or wrap variable parts in useEffect.
Target container is not a DOM element — createRoot(null) because the element id is wrong or the script ran before the DOM was parsed. Check selector and load order.
You called ReactDOM.createRoot() on a container that has already been passed to createRoot() — calling createRoot twice on the same node. Call once, hold the root, call root.render on subsequent updates.
renderToReadableStream is not a function — using the Node import on edge runtimes. Switch to react-dom/server.edge.
flushSync was called from inside a lifecycle method — flushSync cannot be called during a render. Call it from an event handler or useEffect.
useId() returned a different value on the server and client — a sign your <head> injection wraps <App /> differently between SSR and CSR. Use the same tree root in both.
When NOT to use this
- Non-DOM targets. React Native, ink (terminal), react-three-fiber (canvas) use their own renderers.
- Static HTML output only. If you never need hydration,
react-dom/serveralone (no client bundle) is enough — but consider Astro or a static site generator instead. - Bundle-size-critical embeddable widgets. Preact (~3 KB via
preact/compat) is API-compatible; use it where every KB matters.
See also
- JavaScript: react-basics — components, hooks, JSX
- npm: react — core library reference
- Concept: api — server components and the React API boundary