Popularized by Pinterest, Instagram, and others in the 2010s. Modals are typically used as a kind of “detail” view to focus on a particular object in a collection (like a Pinterest board) while not taking you completely out of the context of the parent page.
There is an example on the React Router 6 Repository on how to implement this pattern by using state
and backgroundLocation
to keep the parent page visible and show the modal on top.
How do you achieve the same results with React Router Data Routers (^6.4.0) and Remix? Let’s find out!
First, when using loaders
we will be unable to use the same mechanism of setting a state.backgroundLocation
because the route state doesn’t exist at the point where we define our routes:
// We can't access state here!
const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: []
}
)]
Source Code of this attempt here
So there are two options for Modals with Data Loaders, both of them have their own pros and cons depending on your use case:
Modals using Nested Routing
The first option is to use React Router’s nested routing capabilities to enable a modal that can be shown inside its parent route:
Let’s use the same example from the React Router repository. We want to show a modal whenever the user clicks on an image and then have the image detail view inside the modal instead of a new page.
Routing configuration:
const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
{
element: <Layout />,
children: [
{
index: true,
element: <Home />,
},
{
path: 'img/:id',
element: <ImageView />,
},
{
path: 'gallery',
element: <Gallery />,
children: [
{
path: 'img/:id',
element: <Modal />,
},
],
},
{
path: '*',
element: <NoMatch />,
},
],
},
],
},
]);
The Modal
component is now a nested route inside the Gallery
component.
In Remix just create a new file folder structure to create the nested route
routes
| index.tsx
| gallery
| index.tsx
| modal.tsx
Next step, we need to add an Outlet
to the Gallery
component to render the modal
export function Gallery() {
return (
<div style={{ padding: '0 24px' }}>
<h2>Gallery</h2>
...
<Outlet />
</div>
);
}
Pros:
- Modals can use their own
loaders
: If your modals need to access data fetching, they can be assigned their own loader to separate them from the parent route and work independently. - It doesn’t require a lot of setup: They are just nested routes that render on top of the parent route.
- Persistent navigation: Because the modals are just regular routes, you can reference them, open and close them using the URL path.
Cons:
- Modals need to be configured under a route, which means that it’s not possible to render them in the root
/
URL - Modals will be displayed only inside a defined nested route and cannot be accessed from any page. If you want to show them on a different path, you need to create those routes manually over and over again.
- If you want to move a modal, you must refactor the entire route segment that uses it.
- It is sometimes hard to keep the context of the background page because outlets have to be in the right place.
Modals Using Search Params
An alternative to Nested Routes is to use the Search Params in the URL (Use the platform! 💪)
To open a modal with the picture from the gallery, we will need to navigate to the following URL path:
?modal-type=gallery-img&gallery-img-id=1
Then we can access the URL Search Params in the root loader of the application:
export const loader: LoaderFunction = ({ request }) => {
const queryParams = new URL(request.url).searchParams;
const modalType = queryParams.get('modal-type');
const galleryImgId = queryParams.get('gallery-img-id');
if (modalType === 'gallery-img') {
if (!galleryImgId) {
console.error(
"must pass the gallery-img-id param if you want to render the 'gallery-img' modal"
);
return null;
}
return {
modalType: 'gallery-img',
galleryImgId,
};
}
return null;
};
Then in the render function of the root of your application, we check for the modal type and the parameters.
export default function App() {
const modalProps = useLoaderData() as ModalProps;
return (
<div>
<h1>Modal Example</h1>
<Modal modalProps={modalProps || null} />
<Outlet />
</div>
);
}
Inside the Modal
Component, we can check for the modalType
and render a different modal and content depending on that value. You can
register and create as many modals as required using a simple interface and conditional rendering.
Pros:
- Modals are global; You can use them in the root URL path
/
and can be opened from anywhere in the application. - Easy to refactor and move around within the application.
They also support persistent navigation and manage their visibility state using the URL.
Cons:
- These modals can’t have their own loaders, so they either need to request their data inside their render function (Render then Fetch) or get their data passed as props from a parent.
- They have to be “registered” in the root of your application using a modal rendering engine or utility which could be harder to maintain in the long term.
- The Search Params in the root will trigger all the other loaders, which could cause re-rendering and performance issues. A potential solution is to introduce
shouldRevalidate()
if you don’t want to re-run loaders.
Conclusion
One pattern is not better than the other; both have advantages and disadvantages, depending on your use case.
If you need your modals to have their own loaders, then use the nested routing approach but bear in mind that they are not very flexible and easy to refactor.
If you want to create “global modals” that can be opened from anywhere and are more flexible, you can use the Search Params approach; however, be aware of tight coupling in the root of the application and the performance implications of not using loaders.
Credits:
Jon, who made a video on how to create Modals using Search Params and also a repository with the complete example here
And as usual Matt from the Remix team who’s always so helpful in answering these type of questions on Discord.