React · lesson 14 of 22
Beginner
5Intermediate
12- 6 Effects and useEffect
- 7 You Might Not Need an Effect
- 8 The Rules of Hooks
- 9 useReducer and useContext
- 10 Custom Hooks
- 11 Measuring Re-renders: memo, useMemo, and useCallback
- 13 Routing with React Router
- 14 Portals with createPortal
- 15 Error Boundaries
- 16 Suspense and lazy
- 17 Reading Promises with use, No Framework Needed
- 18 Retrying After an Error with react-error-boundary
Advanced
5Also part of React
5Portals with createPortal
Use createPortal to render a modal into document.body, prove in a real test that clicks still bubble through React's component tree instead of the DOM tree, and see the real error React throws when a portal reaches the server renderer.
By SkillByExample · Published
createPortal renders a component’s children into a DOM node outside its parent’s own DOM hierarchy, while leaving that component exactly where it is in the React tree. The DOM moves; the tree — props, context, event handling — doesn’t. (Keyboard focus and screen-reader behavior for a portaled modal are a separate concern; see the WAI-ARIA dialog pattern rather than a worked example here.)
Project Setup
The same kind of Vitest project as State with useState, plus a tsconfig.json so npx tsx can run one plain script without jsdom:
{
"name": "react-app",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"vitest": "5.0.5",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.8",
"@testing-library/jest-dom": "7.0.1",
"@vitejs/plugin-react": "6.1.2",
"jsdom": "30.1.3",
"tsx": "4.23.15"
}
}
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.js"],
},
});
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
afterEach(() => {
cleanup();
});
{
"compilerOptions": {
"module": "nodenext",
"jsx": "react-jsx",
"noEmit": true
},
"include": ["src"]
}
Every command below runs inside Docker’s node:24.18.1 image, from /root/react-app, with NO_COLOR=1, and grep strips Vitest’s own Start at/Duration lines, since their numbers change on every run:
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-portals-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npm install --no-audit --no-fund
added 101 packages in 27s
npm warn allow-scripts 1 package has install scripts not yet covered by allowScripts:
npm warn allow-scripts esbuild@0.28.2 (postinstall: node install.js)
npm warn allow-scripts
npm warn allow-scripts Run `npm approve-scripts --allow-scripts-pending` to review, or `npm approve-scripts <pkg>` to allow.
DOM Placement, Event Bubbling, and Context, All at Once
Modal calls createPortal with document.body as the target, so its <div role="dialog"> never becomes a DOM child of whatever rendered Modal — but it still reads theme from a context provided by that same ancestor, and a click inside it still reaches that ancestor’s own onClick:
// src/portals/ThemeContext.tsx
import { createContext } from "react";
export const ThemeContext = createContext("light");
// src/portals/Modal.tsx
import { createPortal } from "react-dom";
import { useContext, type ReactNode } from "react";
import { ThemeContext } from "./ThemeContext.js";
type ModalProps = { children: ReactNode };
export function Modal({ children }: ModalProps) {
const theme = useContext(ThemeContext);
return createPortal(
<div role="dialog" data-theme={theme}>
{children}
</div>,
document.body
);
}
// src/portals/App.tsx
import { ThemeContext } from "./ThemeContext.js";
import { Modal } from "./Modal.js";
export function App() {
return (
<ThemeContext.Provider value="dark">
<section onClick={() => console.log("section saw the click")}>
<Modal>
<button>OK</button>
</Modal>
</section>
</ThemeContext.Provider>
);
}
App never renders a <button> directly inside <section> in the DOM — Modal portals it to document.body instead. The test checks container (React Testing Library’s own wrapper, standing in for “section’s real DOM parent”) against document.body, reads the dialog’s data-theme, then clicks the button:
// src/portals/Modal.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { App } from "./App.js";
describe("a portal", () => {
it("places its DOM in document.body but keeps context and bubbling in the React tree", async () => {
const user = userEvent.setup();
const { container } = render(<App />);
console.log(`container: ${container.innerHTML}`);
console.log(`body: ${document.body.innerHTML}`);
expect(container.innerHTML).toBe("<section></section>");
expect(screen.getByRole("dialog")).toHaveAttribute("data-theme", "dark");
await user.click(screen.getByRole("button", { name: "OK" }));
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-portals-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/portals/Modal.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/portals/Modal.test.tsx > a portal > places its DOM in document.body but keeps context and bubbling in the React tree
container: <section></section>
body: <div><section></section></div><div role="dialog" data-theme="dark"><button>OK</button></div>
stdout | src/portals/Modal.test.tsx > a portal > places its DOM in document.body but keeps context and bubbling in the React tree
section saw the click
✓ src/portals/Modal.test.tsx > a portal > places its DOM in document.body but keeps context and bubbling in the React tree 219ms
Test Files 1 passed (1)
Tests 1 passed (1)
Three facts, in the order the log prints them:
container: <section></section> — section has no DOM children at all from React Testing Library’s point of view, even though Modal is right there in the JSX between its opening and closing tags. body: <div>... shows where the dialog actually went: document.body already held the <div> React Testing Library wraps container in, and the portaled <div role="dialog" data-theme="dark"><button>OK</button></div> lands as that <div>’s next sibling — a DOM child of body, not of section.
data-theme="dark" on the dialog is the second fact: Modal read it from useContext(ThemeContext), and the ThemeContext.Provider that supplies "dark" wraps section in the React tree, several DOM nodes away from where the dialog physically sits. Context follows the React tree, same as it would with no portal involved.
section saw the click is the third: clicking the OK button — physically a child of document.body, nowhere near section in the DOM — still reaches section’s own onClick. React dispatches synthetic events by walking the tree it rendered, and in that tree Modal, and everything Modal returns, is still nested inside section.
Portals and the Server Renderer
createPortal needs a second real DOM node to render into — something a server has no equivalent for. A plain script passes a fake { nodeType: 1 } object as that target, just to get past createPortal’s own check and see the renderer’s actual complaint:
// src/portals/ssr.tsx
import { renderToString } from "react-dom/server";
import { createPortal } from "react-dom";
const fakeContainer = { nodeType: 1 } as unknown as Element;
function Dialog() {
return createPortal(<p>Hello</p>, fakeContainer);
}
try {
renderToString(<Dialog />);
} catch (error) {
console.log(`threw: ${(error as Error).message}`);
}
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-portals-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx tsx src/portals/ssr.tsx
threw: Portals are not currently supported by the server renderer. Render them conditionally so that they only appear on the client render.
That message is React’s own: renderToString has no second DOM tree to render Dialog’s portal into, so it throws rather than silently dropping the content. document.body would have thrown first with a plain document is not defined, since there’s no document global in a server script at all — the fake { nodeType: 1 } object exists only to reach this specific error instead of that one. React’s own fix is in the message: check where the code is running and skip the createPortal call entirely during a server render, calling it only once the component has mounted in a browser.
Practice
1. Call event.stopPropagation() inside the dialog <div>’s own onClick, and confirm the click no longer reaches section.
Solution
// src/portals/practice/StoppedModal.tsx
import { createPortal } from "react-dom";
import { useContext, type ReactNode } from "react";
import { ThemeContext } from "../ThemeContext.js";
type ModalProps = { children: ReactNode };
export function Modal({ children }: ModalProps) {
const theme = useContext(ThemeContext);
return createPortal(
<div
role="dialog"
data-theme={theme}
onClick={(event) => event.stopPropagation()}
>
{children}
</div>,
document.body
);
}
// src/portals/practice/StoppedApp.tsx
import { ThemeContext } from "../ThemeContext.js";
import { Modal } from "./StoppedModal.js";
export function App() {
return (
<ThemeContext.Provider value="dark">
<section onClick={() => console.log("section saw the click")}>
<Modal>
<button>OK</button>
</Modal>
</section>
</ThemeContext.Provider>
);
}
// src/portals/practice/StoppedModal.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { App } from "./StoppedApp.js";
describe("stopPropagation inside a portal", () => {
it("prevents the click from reaching an ancestor outside the portal's DOM parent", async () => {
const user = userEvent.setup();
render(<App />);
await user.click(screen.getByRole("button", { name: "OK" }));
console.log("click handled, no section log above");
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-portals-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/portals/practice/StoppedModal.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/portals/practice/StoppedModal.test.tsx > stopPropagation inside a portal > prevents the click from reaching an ancestor outside the portal's DOM parent
click handled, no section log above
✓ src/portals/practice/StoppedModal.test.tsx > stopPropagation inside a portal > prevents the click from reaching an ancestor outside the portal's DOM parent 179ms
Test Files 1 passed (1)
Tests 1 passed (1)
Compare this to the lesson’s own output above: there, clicking OK printed section saw the click right after the two console.log lines. Here it doesn’t print at all — stopPropagation stops the event at the dialog <div>, before React’s synthetic event system ever walks further up the tree to section. stopPropagation follows the same React tree the event bubbled through in the first place; it has nothing to do with the DOM tree the portal placed things in.
2. Change the ThemeContext.Provider value to "light" and confirm the dialog’s data-theme follows it, while container’s own HTML stays the same.
Solution
// src/portals/practice/LightApp.tsx
import { ThemeContext } from "../ThemeContext.js";
import { Modal } from "../Modal.js";
export function App() {
return (
<ThemeContext.Provider value="light">
<section onClick={() => console.log("section saw the click")}>
<Modal>
<button>OK</button>
</Modal>
</section>
</ThemeContext.Provider>
);
}
// src/portals/practice/LightModal.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { App } from "./LightApp.js";
describe("a different context value", () => {
it("changes the portaled dialog's data-theme without changing container", () => {
const { container } = render(<App />);
console.log(`container: ${container.innerHTML}`);
console.log(`dialog theme: ${screen.getByRole("dialog").getAttribute("data-theme")}`);
expect(container.innerHTML).toBe("<section></section>");
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-portals-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/portals/practice/LightModal.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/portals/practice/LightModal.test.tsx > a different context value > changes the portaled dialog's data-theme without changing container
container: <section></section>
dialog theme: light
✓ src/portals/practice/LightModal.test.tsx > a different context value > changes the portaled dialog's data-theme without changing container 163ms
Test Files 1 passed (1)
Tests 1 passed (1)
container is unchanged — still just an empty <section></section> — because nothing about container’s own content depends on the theme. dialog theme: light shows Modal picked up the new context value, proving the context read inside the portal isn’t special-cased: it works exactly the way it would for a component with no portal at all.
3. Target a dedicated element instead of document.body directly: create one with document.createElement("div"), append it to document.body, and portal into that instead.
Solution
// src/portals/practice/DedicatedRootModal.tsx
import { createPortal } from "react-dom";
import { type ReactNode } from "react";
type ModalProps = { children: ReactNode; target: Element };
export function Modal({ children, target }: ModalProps) {
return createPortal(<div role="dialog">{children}</div>, target);
}
// src/portals/practice/DedicatedRoot.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { Modal } from "./DedicatedRootModal.js";
describe("a portal targeting a dedicated element", () => {
it("places the dialog inside that element, not as a direct child of body", () => {
const modalRoot = document.createElement("div");
modalRoot.setAttribute("id", "modal-root");
document.body.appendChild(modalRoot);
render(<Modal target={modalRoot}>{<button>OK</button>}</Modal>);
console.log(`body: ${document.body.innerHTML}`);
expect(screen.getByRole("dialog").parentElement).toBe(modalRoot);
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-portals-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/portals/practice/DedicatedRoot.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/portals/practice/DedicatedRoot.test.tsx > a portal targeting a dedicated element > places the dialog inside that element, not as a direct child of body
body: <div id="modal-root"><div role="dialog"><button>OK</button></div></div><div></div>
✓ src/portals/practice/DedicatedRoot.test.tsx > a portal targeting a dedicated element > places the dialog inside that element, not as a direct child of body 170ms
Test Files 1 passed (1)
Tests 1 passed (1)
body’s HTML now shows two things: the dialog nested one level deeper, inside <div id="modal-root">, instead of sitting as a direct sibling of it the way it did against document.body earlier in the lesson — and, trailing after it, an empty <div></div>. That empty div is React Testing Library’s own container, still appended to body as always; it’s empty because Modal renders nothing at its own place in the tree besides the portal itself. createPortal’s second argument can be any DOM node already attached to the document — document.body is only the simplest choice, not a requirement.
4. Remove the fake { nodeType: 1 } container from the server script and pass document.body instead, to see what error a server script actually hits first.
Solution
// src/portals/practice/ssr-real-document.tsx
import { renderToString } from "react-dom/server";
import { createPortal } from "react-dom";
function Dialog() {
return createPortal(<p>Hello</p>, document.body);
}
try {
renderToString(<Dialog />);
} catch (error) {
console.log(`threw: ${(error as Error).message}`);
}
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-portals-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx tsx src/portals/practice/ssr-real-document.tsx
threw: document is not defined
document is not defined — a plain Node script has no document global at all, so the script never even reaches createPortal’s own check; it fails just evaluating the argument. That’s exactly why the lesson’s own script used a fake { nodeType: 1 } object: it’s a stand-in good enough to get past that first failure and show React’s actual, more specific complaint about portals and the server renderer.