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