One Popup to Rule Them All

Last updated: 2023-08-06 (7 months ago)
Popups are a common sight when browsing, and use cases range from asking the user for a confirmation "Are you sure you want to exit?", to the ever present prompt "Do you accept all cookies?". On this page we will explore two ways popups can be incorporated into a React app.
As is often the case, there is no silver bullet when it comes to implementing popups, it will depend on your needs, your application and the level of customisation required. The purpose of this page is to explore ways to include a popup into the React structure of an app, not the actual modal itself, as we will be using Headless UI "Dialog" component for each scenario. Let's dive into it.

One-off simple popup from a root div

The following implementation is great for simple popups that contain only text, or content that isn't meant to interact back with the page that spawned it. The idea is to add an anchor div at the top of the HTML tree, and render a simple popup inside. Showing and hiding the popup is done through an event listener.
Pros & ConsPros:
  • No clutter code, it does not add anything to the render of the parent component;
  • Simple implementation, every popup is displayed via the same div.
Cons:
  • Awkward to interact with the parent component, as the popup lives outside the render;
  • Not meant to display nested popups, but rather one popup at a time.
To start implementing this type of popup, one must start by adding a div high up in the HTML tree, that will act as an anchor used exclusively to render a popup inside. It does not stricly need to be at the top, it's arbitrary, but I feel like it makes the most sense to place it that way.
1 2 3 4 5 6 7 8 9 10 11 <html> <head><!-- omitted for clarity --></head> <body> <div> <!-- body of your app goes here --> </div> <div id="anchorForPopup"> <!-- we'll render the popup inside here --> </div> </body> </html>
Once that's set up, we follow up by creating a helper function that we'll call everytime we want to display the popup. The function takes as only parameter the content we'd like the popup to receive. It is grabbing the div via id, and is rendering a modal component 'RootModal' whose children are directly passed from the props. We also added a small usage example of how to call the helper.
1 2 3 4 5 6 7 8 9 10 11 12 import ReactDOM from "react-dom/client"; export const openAsRootModal = (component: any) => { const modalAnchorElement = document.getElementById('anchorForPopup'); const root = ReactDOM.createRoot(modalAnchorElement); root.render(<RootModal unmount={() => root.unmount()}> {component} </RootModal>) } /** In your React component just call it like so **/ openAsRootModal(<div>Hello World</div>)
Finally the only bit of code left is the RootModal component, which like mentioned previously is provided by Headless UI. Note that in order to keep the snippet concise, I stripped the component down to the bare minimum, and removed most of the css classes. The modal is pretty standard, an overlay, a dialog box, the dynamically injected children and a 'Cancel' button to close it down.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import { Fragment, useEffect, useRef, useState } from 'react' import { Dialog, Transition } from '@headlessui/react' export const RootModal = ({ unmount, children }: any) => { const cancelButtonRef = useRef(null) const [open, setOpen] = useState(true) return <Transition.Root show={open} as={Fragment}> <Dialog as="div" initialFocus={cancelButtonRef} onClose={() => { setOpen(false); unmount(); }}> <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> <Dialog.Panel> {children} <div> <button type="button" onClick={() => { setOpen(false); unmount(); }} ref={cancelButtonRef}>Cancel</button> </div> </Dialog.Panel> </Dialog> </Transition.Root>}
Before elaborating on the pros and cons of that method, let's see the final product in action. Click below to display the popup:One of the weaknesses of that method is the difficulty that quickly arises if you want to display several popups at once, when they are nested for example. Indeed, we can see that when the modal is closing down (either by clicking 'Cancel', or by clicking on the gray overlay), we are calling a function to 'unmount' our component. Basically we are cleaning up the anchor div of its content, in order to be ready to show another popup the next time around.
On the other hand, this method is great if you are planning to render simple components, because you can invoke as many popups as you wish in your React component, and your render function isn't going to grow any larger.
Another weakness discussed earlier boils down to the fact that the modal is called up through an event listener, and lives outside the render function of the component that called it. This means interacting with the state within that popup can lead to unexpected results. See the example below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // Controlled counter component type CounterProps = { counter: number, setCounter: any } const Counter = ({ counter, setCounter }: CounterProps) => ( <> <p>Increment counter (current value = {counter})</p> <button onClick={() => setCounter(counter + 1)}>+</button> </>); /* Simple page that uses the counter in the render + in a popup */ const Dashboard = () => { const [counter, setCounter] = useState(0); const openCounterInPopup = () => { openAsRootModal(<Counter counter={counter} setCounter={setCounter}/>); } return <> <Counter counter={counter} setCounter={setCounter} /> <button onClick={openCounterInPopup} /> </> }
This renders two counters that we can increment by clicking a button. Notice how we are passing the same props to both counter, one within the render itself, the other opened via popup. Now that looks like a pretty straightforward code, and at first glance you would expect both counter to act the same way. You can try yourself:
Increment counter (current value = 0)
The counter within the render is fine, each click actually adds 1 to the counter, but things get weird when you open the popup. The counter that's inside is displaying correctly the current value upon showing up, but when you click on the "+" button, the value does not change. Besides, if you look at the value of the counter that's outside the popup though, it was indeed increased by 1, but that works only once! Clicking again on the increment button won't change either counter. Peculiar.
The explanation behind this behaviour is like so:
  • every time you click on the "+" button outside the popup, the component re-renders with an updated 'counter' value. That value is directly passed to both Counter components. That's why both show the correct value at first.
  • when you open the popup, you are rendering a Counter within the anchor div, that we inserted at the top of the HTML tree, well outside the Dashboard component. For this reason alone, its counter value will never be affected by any re-render of the Dashboard component.
  • now if you click on the "+" within that popup, the 'setCounter' method is called, and adds 1 to the counter value, naturally. Since the state has changed, the Dashboard component re-renders, and the first Counter is updated with the new value. However, the second counter is oblivious to this change, has explained in the previous bullet point.
  • the reason why you see the counter value outside the popup being updated only once though, is because if you click again on the "+" button, the setCounter method is called again with the value held by that Counter within the popup, which is still the old one and won't ever change.
We can half-fix this problem by using explicitly the latest value of the state within the Counter component, and that will fix the value shown outside the popup, which will be able to be increment normally via the "+" button of the popup.
1 2 3 4 5 6 7 8 // Controlled counter component type CounterProps = { counter: number, setCounter: any } const Counter = ({ counter, setCounter }: CounterProps) => ( <> <p>Increment counter (current value = {counter})</p> // see this line, we are passing a function to the setCounter, to access the latest state value <button onClick={() => setCounter((counter:number) => counter + 1)}>+</button> </>);
However, that won't fix the counter value displayed inside the popup, so let's wrap up this part and move on to another way of handling popups that work better when a React state is invovled.

Popup as part of the render

This method is the common way to add a popup to a component, as part of the render function. That way we are freeing ourselves from the weaknesses we've experienced in the previous section.
Pros & ConsPros:
  • Easy to interact with the state;
  • More options to customise with reusable buttons, as the state is accessible.
Cons:
  • Adds a small complexity to the component, as state must be used to open and close the popup.
Let's take a look at a simple implementation of this method, the modal itself is extremely similar:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 export const Modal = ({ open, setOpen, children }: any) => { const cancelButtonRef = useRef(null) return <Transition.Root show={open} as={Fragment}> <Dialog as="div" initialFocus={cancelButtonRef} onClose={() => { setOpen(false); }}> <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> <Dialog.Panel> {children} <div> <button type="button" onClick={() => { setOpen(false); }} ref={cancelButtonRef}>Cancel</button> </div> </Dialog.Panel> </Dialog> </Transition.Root>}
As opposed to the first method, here we have a controlled modal that does not use state in itself, but rather we pass it from the parent component. We also got rid of the unmounting routine that is no longer needed.
1 2 3 4 5 6 7 8 9 10 11 /* Simple page that uses the modal */ const Dashboard = () => { const [openPopup, setOpenPopup] = useState(false); return <> <Modal open={openPopup} setOpen={setOpenPopup}> <p>Hello World</p> </Modal> <button onClick={() => setOpenPopup(true))} /> </> }
Notice that we now easily have access to a variable telling us whether the modal is opened or closed, which is convenient to show a loading icon while the popup is open for example. See how the button below does it:In my opinion, this method is the best in most cases, as you can even pass a variable inside the modal, to dynamically change the content without duplicating modals.
1 2 3 4 5 6 7 8 9 10 11 12 13 /* Simple page that uses the modal */ const Dashboard = () => { const [openPopup, setOpenPopup] = useState(false); const modalContent = someCondition ? <ComponentA /> : <ComponentB />; return <> <Modal open={openPopup} setOpen={setOpenPopup}> {modalContent} </Modal> <button onClick={() => setOpenPopup(true))} /> </> }
Please find a simple implementation of this below:

Conclusion

We have covered two different ways to display a popup in a React application. The go-to method I would recommend is the second one, simply because interacting with the state is so much easier. The other method is still good to know however, if for some reason you end up in a situation where you do not want your popup to be rendered at the same time as your parent component.

References