Skip to content

Portals

Video Summary

In the last module, we saw how unstyled component libraries like HeadlessUI provide handy primitives like Dialog that solve a bunch of accessibility challenges for us (eg. focus management).

These libraries do one other thing, and if you're not aware of it, it can really trip us up. It seems baffling, like it's violating the rules of React.

As a quick reminder, we rendered our Modal component in a Header component:

function Header() {
const [isModalOpen, toggleIsModalOpen] = useToggle(false);
return (
<header>
<Modal
title="Log in"
isOpen={isModalOpen}
handleDismiss={() => toggleIsModalOpen(false)}
>
<LoginForm />
</Modal>
<button onClick={toggleIsModalOpen}>Log in</button>
</header>
);
}

When we inspect this using the browser devtools, the Elements pane tells a very different story:

Screenshot of Elements pane

At the very start of the course, we learned that React applications are injected into a <div> in the HTML. We saw code like this:

import React from 'react';
import { createRoot } from 'react-dom/client';
const container = document.querySelector('#root');
const root = createRoot(container);
root.render(<App />);

In the elements pane, though, the HeadlessUI component is not rendered in the <div id="root"> that houses our application. Instead, it's teleported into a random other div!

When it comes specifically to components that are meant to float above the UI, things like modals and dropdowns and tooltips, we often use fixed or absolute positioning, to take them out of flow and position them in a precise location. But CSS has some implicit and surprising dependencies.

Suppose our app has the following CSS:

header {
will-change: transform;
}

This declaration seems harmless, but it totally breaks any descendants that try and use fixed positioning!

We want our Modal component to be as simple as possible to use. And having to worry that a great-great-great-grandparent element could break the modal with a single CSS declaration is not simple!

Fortunately, the React team built a solution for this problem: portals.

Libraries like HeadlessUI, Reach UI, and Radix Primitives all use portals under-the-hood to avoid this potential issue. But let's see how we could implement it ourselves, using our home-grown Modal component.

First, we need to create a new container in the HTML

<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="root"></div>
<div id="modal-root"></div>
</body>
</html>

This modal-root div will be the container for our modal, outside the root div used by the rest of the application.

In the Modal.js file, I'll make the following tweaks:

import React from 'react';
import { createPortal } from 'react-dom';
import { X as Close } from 'react-feather';
import FocusLock from 'react-focus-lock';
import { RemoveScroll } from 'react-remove-scroll';
import VisuallyHidden from './VisuallyHidden'
import styles from './Modal.module.css';
function Modal({ title, handleDismiss, children }) {
useEscapeKey(handleDismiss);
return createPortal(
<FocusLock returnFocus={true}>
<RemoveScroll>
<div className={styles.wrapper}>
<div
className={styles.backdrop}
onClick={handleDismiss}
/>
<div
className={styles.dialog}
role="dialog"
aria-modal="true"
aria-label={title}
>
<button
className={styles.closeBtn}
onClick={handleDismiss}
>
<Close />
<VisuallyHidden>
Dismiss modal
</VisuallyHidden>
</button>
<h2>{title}</h2>
{children}
</div>
</div>
</RemoveScroll>
</FocusLock>,
document.querySelector('#modal-root')
);
}

We get createPortal from react-dom, not react, because it specifically works with DOM nodes, whereas the React library is meant to be more abstract and platform-agnostic.

It takes two arguments:

  • A React element to be rendered
  • A container DOM node

Our React app will be mounted inside the standard <div id="root">, but the modal will be mounted in this sibling, <div id="modal-root">. This fixes our problem, because the DOM nodes rendered by Modal are no longer contained by the DOM nodes rendered by Header.

When I first learned about portals, I thought they were a bit of an anti-pattern. It feels weird to lose the symmetry we have between the React tree and the corresponding DOM tree. But ultimately, we need some way to solve for this problem, to allow our modals and tooltips and dropdowns to avoid being "trapped" by the CSS set by their ancestors in the DOM tree.

Ultimately, we don't need to work with createPortal directly very often, but it is worth knowing how it works, since we'll use lots of third-party components that use this technique under-the-hood.

Normally, React elements are turned into DOM nodes in a symmetrical fashion, but as we saw in the video above, portals allow us to "teleport" the output DOM nodes to a target container.

Here's the syntax, for easy reference:

import { createPortal } from 'react-dom';
function Modal({ children }) {
return createPortal(
// The React elements to render:
<div className="modal">
{children}
</div>,
// The target DOM container to hold the output:
document.querySelector('#modal-root')
);
}

You'll also need to edit the HTML file to create that target container:

<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="root"></div>
<div id="modal-root"></div>
</body>
</html>

Here's the final sandbox from the video:

Code Playground

import React from 'react';

import useToggle from './use-toggle';
import Modal from './Modal';
import LoginForm from './LoginForm';
import styles from './Header.module.css';

function Header() {
const [isModalOpen, toggleIsModalOpen] = useToggle(false);
return (
<header className={styles.wrapper}>
{isModalOpen && (
<Modal
title="Log in"
handleDismiss={() => toggleIsModalOpen(false)}
>
<LoginForm />
</Modal>
)}
<button onClick={toggleIsModalOpen}>
Log in
</button>
</header>
);
}

export default Header;

Peeking under the hood

You might be wondering: what does this createPortal function actually do? And why am I returning it?

I was curious as well, and so I decided to log it out:

import React from 'react';
import { createPortal } from 'react-dom';
function Modal({ title, handleDismiss, children }) {
const portal = createPortal(
<FocusLock returnFocus={true}>
{/* All the same stuff inside */}
</FocusLock>,
document.querySelector('#modal-root')
);
console.log(portal);
return portal;
}

As a result, I see an object that looks like this:

{
"$$typeof": Symbol(react.portal),
"children": {
"$$typeof": Symbol(react.element),
"type": {},
"key": null,
"ref": null,
"props": {},
},
"containerInfo": div#modal-root,
"key": null,
}

In this course, we've been talking about how React elements are descriptions of part of the UI. The JSX we write gets compiled into React.createElement calls, and these calls create descriptive objects (the “Virtual DOM”).

It turns out, createPortal is very similar! It creates a description of a portal we want React to create.

This portal object “wraps around” the elements; the children property contains all of the React elements that we want to teleport. And the containerInfo property holds a reference to the DOM node that will host them.

In other words, the createPortal function doesn't directly do any of this work. It creates a description that React uses in the render process, to let the renderer know that this slice of the app needs to be injected into a different container.