Today I created a modal with ReactDOM.createPortal
. This is the closest I've ever come to creating a modal from scratch. A modal window is a "graphical control element subordinate to an application's main window." In other words, any window with a little "x" button that doesn't render until the ad finishes playing. Advertising aside modals are a good way to separate some user action from the main flow the application. Like an advertisement or an important form to subscribe to a newsletter (coming soon!). My experience is that a good modal is difficult to create with vanilla HTML and CSS, and that React only makes a smidge easier.
This method does the complex work of passing your app state from the existing flow into a separate DOM node outside the flow of your app. What does this really mean? We can take a brand new DOM node and start appending our React content to it, irrespective of the rest of our app.
I needed to open an <iframe>
element in a new window, then append the existing React app and some state variables to this new window. At the time this terrified me because I wasn't sure how I was going to do it. I said "new window," not DOM element. Guess what! A new window is ultimately just another new set of DOM elements. The next challenge is trying all this and applying Typescript to the new window, since we're professionals and we use Typescript.
Create a portal with plain Javascript
Opening a new window in Javascript is dead simple:
window.open('https://destination-url.com', '_blank', 'width=1920,height=1080');
With HTML:
<a href="https://destination-url.com" target="_blank">Destination URL</a>
The HTML or Javascript methods open a brand new window pointing to destination-url
. But we left our app, we're no longer in control. How do I retain control of my app with React and a portal? This is an example component that applies ReactDOM.createPortal
.
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
/* this is our destination element */
const modalRoot = document.getElementById("modal-root");
/* Our function component that accepts childdren as a prop */
const Modal = ({ children }) => {
const elementRef = useRef();
elementRef.current = docment.createElement("div");
useEffect(() => {
/* slap one node on to the other and you get a delicious modal sandwich */
modalRoot.appendChild(elementRef.current);
/* clean up when we're done playing */
return () => {
modalRoot.removeChild(elementRef.current);
};
}, []);
return createPortal(children, elementRef.current);
}
function App() {
const [showModal, setModal] = useState(false);
return (
<div>
<!--This element is empty right now-->
<div id="modal-root"></div>
{showModal ?? (
<Modal>
<section style={{
display: "flex",
flexDirection: "row",
height: "25vh",
width: "25vw",
isolation: "isolate",
zIndex: 100
}}>
<span>This is the dern modal</span>
<button onClick={() => setModal(!showModal)}>Miss me with that modal</button>
</section>
</Modal>
<button onClick={() => setModal(!showModal)}>Display Modal</button>
)}
</div>
)
};
What is going on here? We created a new functional component then render the component in our app. This is great because the React state is passed from the "parent" component through our portal to the modal, even though we've broken away from the regular DOM tree. Our modal isn't in the normal flow but it can use the state variables we would normally pass from parents to children. While we used a div
element in this example, we can use any element as our portal, but you probably want to use some kind of boxy element like a section, article, or something "containery." Or a new window!
Create portals with Typescript
Creating a portal with Javascript though isn't adequate because we aren't doing any work to check our element exists at build time. How do I even know what my destination element is? How do I know what props I should be passing? How do I know if this element exists!?
Instead of a div
I built a modal component that creates a new DOM window:
import { useEffect, useRef } from 'react';
interface ModalProps {
children: ReactNode;
closeWindowFunc: () => void;
}
const Modal = ({ children, closeWindowFunc }: ModalProps) => {
/* Refer to our new window on the current render */
const windowRef = useRef<Window | null>(null);
const containerEl = document.createElement('div');
useEffect(() => {
/* TBD */
windowRef.current = window.open('', '', 'popup,width=800,height=600');
}, []);
};
This is the foundation for our portal. Create a new ref
with type Window
that's null when the component loads. One challenge of refs
and Typescript is that a new ref
is possibly null. The transpiler doesn't know for certain that a given element is present on the DOM. Working within that constraint means setting the default value to null
since our Modal shouldn't be visible by default.
This creates another problem. We can't actually assign a new window directly to windwoRef.current
, because there's a chance the ref
doesn't exist. When using refs
and Typescript you must manually manage the component's lifecycle.
Before we can add the window we check the ref
exists in useEffect()
:
/* previous stuff */
useEffect(() => {
if (windowRef.current === null) {
windowRef.current = window.open('', '', 'popup,width=800,height=600');
windowRef.current.document.body.appendChild(containerEl);
}
}, []);
/* more stuff */
Now that we know our ref
exists, you can add the window and container element to the ref
. Manually managing this ref
means all our other steps depend on that ref
, so any transforms to our new window have to be inside useEffect
. Also, the variables must be included in useEffect's
dependency array. At this step, we also include our new components in the portal
so this component is available in the app.
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
interface ModalProps {
children: ReactNode;
closeWindowFunc: () => void;
};
const Modal = ({ children, closeWindowFunc }: ModalProps) => {
const windowRef = useRef<Window | null>(null);
const containerEl = document.createElement("div");
useEffect(() => {
if (windowRef.current === null) {
windowRef.current = window.open("", "", "popup,width=800,height=600");
windowRef.current.document.body.appendChild(containerEl);
windowRef.current.document.title = "My new window";
};
}, [windowRef, containerEl]);
return createPortal(children, containerEl);
Last, like opening the component, you need to manually manage cleanup of the window to prevent memory leaks. Add an event handler and return the window.close()
method from useEffect
:
useEffect(() => {
/* stuff */
windowRef.current.addEventListener('beforeunload', () => closeWindowFunc());
return () => {
if (elementRef.current !== null) {
closeWindowFunc();
return elementRef.current.close();
}
};
}, [windowRef, containerEl]);
Putting it all together
This is what our completed new window component looks like:
import { useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
interface ModalProps {
children: ReactNode;
closeWindowFunc: () => void;
}
const Modal = ({ children, closeWindowFunc }: ModalProps) => {
/* What element is this */
const windowRef = useRef<Window | null>(null);
const containerEl = document.createElement('div');
useEffect(() => {
if (windowRef.current === null) {
windowRef.current = window.open('', '', 'popup,width=800,height=600');
windowRef.current.document.body.appendChild(containerEl);
windowRef.current.document.title = 'My new window';
windowRef.current.addEventListener('beforeunload', () =>
closeWindowFunc(),
);
}
return () => {
if (elementRef.current !== null) {
closeWindowFunc();
return elementRef.current.close();
}
};
}, [windowRef, containerEl]);
return createPortal(children, containerEl);
};
Last, here's how to call the completed window in the main app:
import { Modal } from './modal';
function App() {
const [showModal, setModal] = useState(false);
/* Now you're thinking with portals */
return (
<div>
{showModal ?? (
<Modal>
<section
style={{
display: 'flex',
flexDirection: 'row',
height: '25vh',
width: '25vw',
isolation: 'isolate',
zIndex: 100,
}}
>
<span>This is the dern modal</span>
<button onClick={() => setModal(!showModal)}>
Miss me with that modal
</button>
</section>
</Modal>
)}
<button onClick={() => setModal(!showModal)}>Display Modal</button>
</div>
);
}
The most important thing I learned from this process is that refs
don't always exist, so you need to check at each step whether the current ref
is available. This applies within the entire scope of the ref
so if you think you checked that your ref
was null, check again.
References
[1] "Portals," ReactJS, Accessed on: Nov. 7, 2022. [Online]. Available: https://reactjs.org/docs/portals.html
[2] D. Gilbertson, "Using a React 16 Portal to do something cool," Medium, Nov. 2017. Accessed on: Nov. 7, 2022. [Online]. Available: https://medium.com/hackernoon/using-a-react-16-portal-to-do-something-cool-2a2d627b0202