Tips for Building a Modal System in React
Most applications require a modal (or dialog) system. While their requirements might differ, they are quite similar for the most part. A challenging aspect is that some requirements are often implicit, with the modal system being part of another feature.
Consider a feature like this:
As a shop owner,
I want the ability to add a new product category via a modal directly from the product creation form,
so that I don't have to leave the form to create a new category if one doesn't exist.
Developers often scramble to build a modal, or nowadays, they might ask ChatGPT (and go for a coffee while ChatGPT generates the code).
Most tutorials suggest something like the following:
const [isOpen, setOpen] = useState(false);
const openModal = () => {
setOpen(true)
}
<>
<button
onClick={openModal}>
Open
</button>
{isOpen && (
<Portal>
<Modal />
</Portal>
)}
</>
…and this is what ChatGPT generated.
This approach, while seemingly straightforward, is naive. It works for managing a single modal but lacks basic features, such as closing the modal with the "ESC" key.
To say this approach is naive is an understatement. It kinda works if you have only one modal. It doesn't have basic features like "ESC" to close the modal.
What is worse it introduces a lot of boilerplate code, leading to a development pattern filled with copy-paste solutions. 🙄
So, let's see how to architect a better modal system.
Requirements
Before starting to build, it's crucial to consider what needs to be built.
Here are some of the obvious requirements for a good modal system:
Provide an easy way to open and close modals programmatically.
Allow modals to return a "result," for example, selecting a product from a search.
Modal can be closed by
close button
ESC button
click/tap outside the modal
Ensure accessibility
Support different layout or frame types, such as a white box, full-screen gallery, etc
Less obvious requirements include:
Modals can have URLs
Example: In Product Hunt, posts can be opened in modal form or as separate pages
We should define how the back button is handled
If the modal has a URL, the back should close the modal
Modals can be stacked. A modal can open another modal.
Example: Modal is for product creation and can trigger modal for category creation.
Scroll position
Don't scroll the background page
Preserve the scroll position on the page
Preserve scroll position on previously open modals when stacking
Data fetching
Lazy loading of the JS/CSS code for modal components
API fetching for the content of the models
Handle modal layout on mobile and desktop.
There should be a way for components rendered in inside or outside modals to know this
Example: Infinite scroll component should listen for scroll events of a document or top modal
Architecting the Modal System
I call "support system", a code whose main purpose is to help developers build features.
The modal system is a good example of such a system. Most of the time, developers should build specific modals or open modals. They interact with the system and don't change the system often.
I tend to approach the "support systems" as functional calls. They should be "black boxes" or functions. Developers should care about their input and output, not much about their implementation.
When designing a "support system", the most important thing is to
Make the common operations easy (the public interface)
Put most of the complexity in the "black box" (the implementation details)
You should be able to change implementation details without changing the interface. In this way, you can have a partial implementation of the system and still use it to implement features.
For the modal system, the most common operations are:
Open modals
Implementing modal components
Let's start with those interfaces.
Open Modal
The API for opening a modal, which I have used for years has an `openModal` function, which opens a modal component.
openModal({
content: <MyModal />
});
This function should be able to be called from anywhere. It only depends on something.
`openModal` accepts an object so that we can pass different options, like:
openModal({
path: '/posts/id',
frame: 'box',
onClose: closeHandle,
content: (
<Post postId={id} />
),
});
With this simple interface, we are future proved:
We can easily add more options in the future, like, for example, "sound", which triggers different sounds when the modal is open/closed 🔊
We don't care if stacking is implemented. Maybe in v1, we don't add stacking for modals. 💨
How we render the modal can also be changed later
One detail that might be missed is the `content` attribute. There, we pass a rendered React component. We can do this because JSX passed converts to a JS structure that we can pass around.
We can pass props to this component. This is how we handle the "return result" from the modal. We use a callback. 🙈
openModal({
content: (
<CreateCategoryModal
onCreate={onCreate} />
),
});
I have tried many more ways to pass data as a modal result, but the good old callback is still the best. 💪
Modal Component
This is where most of the implementation work will be taken. When "modal system" is mentioned, this is where people usually think - how modals look.
function MyModal() {
return (
<ModalUI.Frame>
<ModalUI.Title />
<ModalUI.Body />
<ModalUI.Footer />
</ModalUI.Frame>
)
}
This is useful, and I also use this depending on the UI in the project. However, we can do even better.
Often, models need to fetch data to operate. This should be built into the model setup.
For pages, I usually extract a helper named createPage to help me with things like data fetching.
I do the same for modals - have a helper named `createModal` that deals with data fetching and modal.
Here is how it can look:
export default createModal({
// Let's say we use GraphQL
// Code will be similar to REST API
query: GRAPHQL_QUERY,
queryVariables: (props) => { ... },
// This sets document.title
title: (data) => data.post.title,
// We can have other useful props
frame: 'box',
// Component UI
renderComponent: ({ data, ...props }) {
return ...
},
});
But why not use a simple `useQuery` hook? Why all of this? 🤔
"Make the common operations easy". If we have a `useQuery`, we still need to manually write code every time to show the loading indicator, deal with errors, handle changing the document titles.
The next thing I like to do with a modal is to lazy load their code with things like next/dynamic, so they are not included in the page bundle because modal content is often optional for the page.
Here is how that layout is out.
/components/MyModal/index.js
import dynamic from 'next/dynamic';
import dynamic from 'next/dynamic';
import Loader from '@/utils/modals/Loader';
export default dynamic(() => import('./Content'), {
loading: () => <Loader />,
});
/components/MyModal/Content.tsx
... modal itself
For the modal, they import MyModal from @/components/MyModal
, and all the rest is handled automatically.
Next.js lazy load the modal. We show a nice animated loader for this
`createModal` loads the content. It shows the same animated loader, so there is no content shift.
The `createModal` is self-contained. Some models might not use it at all. You can check a sample implementation of it in this gist. 💻
The System Implementation
We have the public interface of our system. Now, let's go into technical details.
The `openModal` and `closeModal` use the EvenBus from my previous post.
export function openModal(options) {
emit('modalOpen', options);
}
export function closeModal() {
emit('modalClose');
}
The event bus does the heavy lifting for this. I first extracted EventBus for a modal system.
The event bus sends those events in a `ModalContainer` component. The responsibilities of these components are:
Listens for open/closed events
Renders models and their overlays
Handles stacking of modals
Deals with scrolling
Handles close of modals via esc, overlay
This is quite a lot and full of technical details.
The whole modal system without the UI elements is about ~ 400 lines of TypeScript.
Putting in a post will be too long (even for me). 😅
Because of this, I have extracted the code into a gist with detailed comments.
👉 Code of a working modal system 💻
Here is a video walkthrough of the code. 🍿
Q&A
Why not make this an open-source package?
I always needed more time to do so. I might do it someday.
One tricky area is that the modal system often needs various tweaks for each project.
Generalizing it as an open-source package might get too complex and heavy.
What do you think about the new HTML dialog element? How does it fit with this structure?
I like "dialog" html element. I use it a lot at Angry Building.
More people should use it.
I like this minor feature a lot -> when the dialog is opened and there are form elements, the first element auto-focuses.
What I have shown here will work with or without `dialog`
Conclusion
Now you have the wireframe of a good modal system. Of course, every application has its requirements.
One "meta" thing about the design of this system is the clear separation between the public interface and implementation details.
We made the common operations easy and hid enough implementation details so we could easily extend the system over time.
If you have any questions or comments, you can ping me on Threads, LinkedIn, Mastodon, Twitter or just leave a comment below 📭