cheat sheet

react-dom

Package-level reference for react-dom — client/server rendering, hydration, portals, flushSync, and the React 19 root API.

#npm#package#react#dom#ssrupdated 05-31-2026

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.

bash
npm install react react-dom

Output: added react and react-dom to dependencies

bash
pnpm add react react-dom

Output: added 2 packages

bash
yarn add react react-dom

Output: added react and react-dom

bash
bun add react react-dom

Output: installed react and react-dom

bash
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/server runs in Node 18.18+ / 20+ for streaming server rendering. Edge runtimes use react-dom/server.edge.
  • Dual ESM/CJS.
  • Versioning: always pin react-dom to the same minor/patch as react. A mismatch surfaces as runtime "Invalid hook call" errors.
  • TypeScript types in @types/react-dom — versioned independently of @types/react but 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 react in 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/clientcreateRoot, hydrateRoot. The client renderer used in 18+.
  • react-dom/serverrenderToString, renderToPipeableStream, renderToReadableStream for 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 testing
  • framer-motion, @react-spring/web — animation
  • react-aria / radix-ui — accessible primitives

Alternatives

PackageTrade-off
preact/compatAPI-compatible swap-in renderer, ~3 KB. Tiny apps and widgets.
solid-js (with solid-js/web)Different framework; fine-grained reactivity.
lit-htmlWeb-components-based rendering. Standards-aligned.
vanilla-jsx / jsx-domJSX with no virtual DOM at all. Niche.
Custom renderer via react-reconcilerBuild 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.

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

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

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

tsx
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).

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

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

tsx
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=production for the bundler. Vite / Next.js / Webpack handle this automatically with npm run build. Without it your bundle is 3× larger and slower.
  • @vitejs/plugin-react or @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/server into client code — it inflates the bundle by ~50 KB.
  • react-dom/server.edge for Workers / Edge. The default react-dom/server imports Node built-ins that fail on edge runtimes.

Performance tuning

  • Use createRoot once. Re-creating roots is expensive. Mount once, call root.render to 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 flushSync unless needed. Synchronous flushes block the main thread and undo concurrent rendering's benefits.
  • useDeferredValue over manual flushSync for "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.

FromToKey changes
react-dom@17react-dom@18createRoot replaces ReactDOM.render; hydrateRoot replaces hydrate. renderToPipeableStream added. Strict mode double-invokes effects in dev.
react-dom@18react-dom@19New resource-hint APIs (preload, preinit, prefetchDNS). Improved hydration error messages. Refs as props (paired with React 19 core).

18 → 19 migration steps:

Before (18):

tsx
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):

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

  1. Upgrade react and react-dom together. Use overrides / resolutions to dedupe.
  2. Upgrade @types/react-dom.
  3. Replace ReactDOM.render / ReactDOM.hydrate calls (these were already gone in 18; old codebases occasionally still have them).
  4. Audit findDOMNode (deprecated since 18; refs are the replacement).
  5. Migrate unstable_batchedUpdates calls — automatic batching makes them unnecessary.

Security considerations

  • dangerouslySetInnerHTML is XSS. React makes it verbose deliberately. Always sanitise untrusted HTML with DOMPurify.
  • 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}. A javascript: URL in a link is XSS. React 19+ disallows javascript: 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

typescript
// vitest.setup.ts
import "@testing-library/jest-dom/vitest";
typescript
// 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

typescript
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

PackageRole
nextApp-router + server components; hides react-dom behind its own runtime
vite + @vitejs/plugin-reactSPA build setup
astro + @astrojs/reactReact islands inside Astro pages
@testing-library/reactDOM-based unit testing
react-error-boundaryError-boundary component
framer-motionAnimations using react-dom's reconciler
react-ariaAccessible 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 elementcreateRoot(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 methodflushSync 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/server alone (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