Route-based Modals with Remix
I love using route-based modals in my apps. Let's see how we can easily build a system for it in Remix!
Modals are a ubiquitous UX pattern. Traditionally, modals are used to simply focus user attention onto some piece of information (i.e. alerts, confirmation dialogs, light boxes, etc.)
But recently, modals are often being used as small, single action screens that are accessible throughout our applications. For example, the "compose new tweet" feature on Twitter displays as a modal:
To provide the best user experience, these modals are usually route-based so that they work within the context of the browser's history. This means hitting the back/forward buttons will close or reopen the modal, and users can share or bookmark the link and be taken back to that same screen with the modal opened.
So, let's see how we can build this type of behavior in Remix. Quick note that all of the source code for this article is available here.
Let's get started...
To make this work, we're going to utilize layout routes in Remix. Layout routes are exactly what they sound like - routes that wrap and define a layout for their children. This concept of nested routing is incredibly powerful as it allows us to match sections of pages in our application to parts of the URL.
We can easily setup a layout route in our project by creating a file and a folder with a matching name. In our case, we'll create a tweets.tsx
and a tweets
folder at the root of our routes
folder. Something like this:
routes
| index.tsx
| tweets.tsx
| tweets
| index.tsx
root.tsx
Now, inside of our tweets.tsx
, we'll add some markup and an <Outlet />
. This <Outlet />
is where we are telling Remix to render the children of this layout.
// tweets.tsx
import { Outlet } from "@remix-run/react";
export default function () {
return (
<div>
Layout
<div style={{ color: "blue", border: "1px solid blue" }}>
<Outlet />
</div>
</div>
);
}
Then, we'll add some basic content to our /tweets/index.tsx
route so we can see how the outlet works.
// /tweets/index.tsx
export default function () {
return <div>Child</div>;
}
Now, when we navigate to /tweets
, we see this:
Notice how we see both strings displayed 🙌.
That's cool!
With this information, we might be tempted to just make our modals child routes of our layout route in Remix. For example, having a tweets/new
route render our modal component with a folder structure like this.
routes
| index.tsx
| tweets.tsx <-- layout route
| tweets
| index.tsx
| new.tsx <-- modal
root.tsx
However, this setup won't work for two reasons. The first is that we are changing the path of our URL from /tweets
to /tweets/new
. That means our content in index.tsx
that appear on /tweets
inside of the layout Outlet
will no longer be rendered. Our modal will appear above an empty layout page.
The second is that our modal will only display over the /tweets
page. In an ideal setup, the NewTweetModal
is accessible by our users from any page.
Which means, we need a way of appending dynamic state information to our routes. In other words, it's time to employ an old friend of the web...
The humble ?search
parameter.
Search, or query, parameters are ways of extending a basic URL to define what content should be displayed on a given page. They're commonly seen on search or filtering pages. However, in our case, we're going to use a ?compose_tweet=true
param to tell Remix to show our modal.
Inside of our layout route, tweets.tsx
, let's add the following code:
// tweets.tsx
export default function () {
const [searchParams] = useSearchParams();
return (
<>
<div>
Layout
<div style={{ color: "blue", border: "1px solid blue" }}>
<Outlet />
</div>
</div>
{searchParams.get("compose_tweet") && <NewTweetModal />}
</>
);
}
Notice how we're checking whether or not the search params returned from the useSearchParams
hook contains compose_tweet
. And, if it does, we render our <NewTweetModal />
.
Now, if we go to /tweets
, we'll see the page we had before. But, if we go to /tweets?compose_tweet=true
:
Yay! Now, we can add as many routes as we want inside of our tweets
folder and if we add our search parameter, our modal will show! But...
Can we do better?
We did say we wanted our modals to be accessible from any page - not just pages that fall after /tweets
. Well, luckily, this is an easy refactor. See, the root.tsx
file in Remix is actually a root layout route. Notice the Outlet
in the code below
export default function App() {
return (
<html lang="en" className="h-full bg-sand">
<head>
<Meta />
<Links />
</head>
<body className="h-full">
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
So, let's add our modal watching code to root.tsx
:
export default function App() {
const [searchParams] = useSearchParams();
return (
<html lang="en" className="h-full bg-sand">
<head>
<Meta />
<Links />
</head>
<body className="h-full">
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
{searchParams.get("compose_tweet") && <NewTweetModal />}
</body>
</html>
);
}
And now, if we go to the root route in our browser /
:
And then we append our search param /?compose_tweet=true
:
And our modal still appears on /tweets?compose_tweet=true
🎉🥳🎉 Amazing!
We just used Remix to create a route-based modal that can appear above any page in our application.
If you're interested in a follow-up article where I show you how to hook up this modal's Form
to a route action
so that it actually creates a tweet and refreshes the feed, tweet at me. We might even make it available from a keyboard shortcut 😉.