Remix Modal Route
In this blog post, we will cover how to display route in a Modal, aka Modal Route, in Remix. We will be using HeadlessUI Dialog/Modal component, but you can easily replace it with your preferred Modal implementation! Lets see how to do this.
Content
Stater Project
I will be using RemixFast to generate codebase for a ready to run Starter project, but you can use the Indie Stack to create your project as well, just create model along with route, and follow along.
Login to RemixFast and click Create
and enter following
App Name: 'ModalRoute'
Type: 'Starter App'
App Name: 'ModalRoute'
Type: 'Starter App'
Next find the newly created app ModalRoute (first one in the list) and select it to launch Visual Editor. In Visual Editor ensure you are on Models
tab and click Add
to create new model and enter following:
Model Name: 'Widget'
`Tab` and create columns
widgetName
widgetNumber
Model Name: 'Widget'
`Tab` and create columns
widgetName
widgetNumber
Click Done
, this will create Widget model. Next click Export Code
(in upper right corner), and select following
App Version: '1.0.0'
Stack: 'The Indie Stack'
App Version: '1.0.0'
Stack: 'The Indie Stack'
Click Export Code
. This will create a ready to run starter project with models and routes. Select Code
tab, and then click Download Code
to download the generated code. Expand downloaded code package and take a minute to examine auto generated code, please do not run/init project just yet.
Seed
Let's seed the Widget table with some data for testing. Copy following code into prisma\seed.ts
just before console.log('Database has been seeded. 🌱');
.
for (let i = 0; i < 33; i++) {
let data: Prisma.WidgetCreateInput = {
widgetName: `Widget ${i + 1}`,
widgetNumber: `W-${i + 1}`,
};
const w = await prisma.widget.create({
data,
});
}
for (let i = 0; i < 33; i++) {
let data: Prisma.WidgetCreateInput = {
widgetName: `Widget ${i + 1}`,
widgetNumber: `W-${i + 1}`,
};
const w = await prisma.widget.create({
data,
});
}
We are creating dummy widget data and inserting into Widget table. Now run npm run init
and you should have database with widgets populated.
Display Item List
We will show users a list of items and when they click an item, show the selected item details in a Modal view, this way preserving the user context.
Find app\routes\widget.tsx
and locate WidgetRoute
function. Replace function with following:
export default function WidgetRoute() {
const { widgetList } = useLoaderData<typeof loader>();
return (
<div className="h-full overflow-auto">
{widgetList.map((w) => (
<div key={w.widgetId} className="m-4 rounded-md border p-4">
<div className="text-large font-semibold">{w.widgetName}</div>
</div>
))}
</div>
);
}
export default function WidgetRoute() {
const { widgetList } = useLoaderData<typeof loader>();
return (
<div className="h-full overflow-auto">
{widgetList.map((w) => (
<div key={w.widgetId} className="m-4 rounded-md border p-4">
<div className="text-large font-semibold">{w.widgetName}</div>
</div>
))}
</div>
);
}
We are using hook useLoaderData get widgetList that was sent from our loader and just iterate over it to display list of widgets.
Start project with npm run dev
and go to http://localhost:3000/widget
You should see first 20 widgets 🚀 You can even turn off JavaScript and it will still show first 20 widgets thanks to Remix SSR! ✨
Display Item Detail
When user selects an item from the widget list, we want to display the selected item details.
We will use Link
component to let user select an item. The to property of link component will let user navigate to item detail by changing URL from /widget
to /widget/${id}
And to show item details, add an Outlet
component. Remix docs define Outlet as "A component rendered inside of a parent route that shows where to render the matching child route". Basically, as the name implies, it provides an outlet to render child route inside the parent route! For now just add <Outlet \>
at the top, just below <div className="h-full overflow-auto">
export default function WidgetRoute() {
const { widgetList } = useLoaderData<typeof loader>();
return (
<div className="h-full overflow-auto">
<Outlet />
{widgetList.map((w) => (
<Link key={w.widgetId} to={`${w.widgetId}`}>
<div className="m-4 rounded-md border p-4">
<div className="text-large font-semibold">{w.widgetName}</div>
</div>
</Link>
))}
</div>
);
}
export default function WidgetRoute() {
const { widgetList } = useLoaderData<typeof loader>();
return (
<div className="h-full overflow-auto">
<Outlet />
{widgetList.map((w) => (
<Link key={w.widgetId} to={`${w.widgetId}`}>
<div className="m-4 rounded-md border p-4">
<div className="text-large font-semibold">{w.widgetName}</div>
</div>
</Link>
))}
</div>
);
}
And remember to import the Outlet component
import { Link, Outlet, useLoaderData } from '@remix-run/react';
import { Link, Outlet, useLoaderData } from '@remix-run/react';
Ok, now lets display details. Find app\routes\widget\$widgetId.tsx
and locate WidgetFormRoute
function. Replace function with following:
export default function WidgetFormRoute() {
//const {widget} = useLoaderData<typeof loader>();
// OR
const { widgetList } = useMatchesData('routes/widget') as { widgetList: Widget[] };
const params = useParams();
const widgetId = +(params.widgetId || 0);
const widget: Widget = widgetList.find?.((r: Widget) => r.widgetId === widgetId) ||
getNewWidget();
return (
<div className=" rounded-md border p-4">
<div className="text-large font-semibold">{widget.widgetName}</div>
<div>{widget.widgetNumber}</div>
</div>
);
}
export default function WidgetFormRoute() {
//const {widget} = useLoaderData<typeof loader>();
// OR
const { widgetList } = useMatchesData('routes/widget') as { widgetList: Widget[] };
const params = useParams();
const widgetId = +(params.widgetId || 0);
const widget: Widget = widgetList.find?.((r: Widget) => r.widgetId === widgetId) ||
getNewWidget();
return (
<div className=" rounded-md border p-4">
<div className="text-large font-semibold">{widget.widgetName}</div>
<div>{widget.widgetNumber}</div>
</div>
);
}
Instead of getting data from useLoaderData
like we did in Widget List route, here we are getting from already loaded parent widget list via useMatchesData hook. We pass route address of parent routes/widget and hook returns loader data for that route. We just then find the selected widget using widgetId parameter. If you are just loading few key fields in the parent list route then use the useLoaderData
hook in detail route to load complete detail data.
Start project with npm run dev
and go to http://localhost:3000/widget
and select a widget, you should see URL change to /widget/:id and UI will display the selected widget details.
Modal Route
Now that we have the basic item detail routing working, lets display Widget details in a Modal!
Start by installing headlessUI
npm install @headlessui/react
npm install @headlessui/react
Next, change WidgetFormRoute
as follows:
export default function WidgetFormRoute() {
//const {widget} = useLoaderData<typeof loader>();
// OR
const { widgetList } = useMatchesData('routes/widget') as { widgetList: Widget[] };
const params = useParams();
const widgetId = +(params.widgetId || 0);
const widget: Widget = widgetList.find?.((r: Widget) => r.widgetId === widgetId) ||
getNewWidget();
//
let [isOpen, setIsOpen] = useState(true);
const navigate = useNavigate();
const handleClose = () => {
setIsOpen(false);
navigate(-1);
}
return (
<Dialog open={isOpen} onClose={handleClose} className="relative z-50">
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="mx-auto w-96 rounded bg-white">
<div className="m-4 flex flex-col rounded-md border p-4">
<div className="text-large font-semibold">{widget.widgetName}</div>
<div>{widget.widgetNumber}</div>
<button className="mt-4 self-end" onClick={handleClose}>
Close
</button>
</div>
</Dialog.Panel>
</div>
</Dialog>
);
}
export default function WidgetFormRoute() {
//const {widget} = useLoaderData<typeof loader>();
// OR
const { widgetList } = useMatchesData('routes/widget') as { widgetList: Widget[] };
const params = useParams();
const widgetId = +(params.widgetId || 0);
const widget: Widget = widgetList.find?.((r: Widget) => r.widgetId === widgetId) ||
getNewWidget();
//
let [isOpen, setIsOpen] = useState(true);
const navigate = useNavigate();
const handleClose = () => {
setIsOpen(false);
navigate(-1);
}
return (
<Dialog open={isOpen} onClose={handleClose} className="relative z-50">
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="mx-auto w-96 rounded bg-white">
<div className="m-4 flex flex-col rounded-md border p-4">
<div className="text-large font-semibold">{widget.widgetName}</div>
<div>{widget.widgetNumber}</div>
<button className="mt-4 self-end" onClick={handleClose}>
Close
</button>
</div>
</Dialog.Panel>
</div>
</Dialog>
);
}
We are using HeadlessUI Dialog/Modal component to display Widget Details. When the route renders in the Outlet, UI will show Dialog as we have it open by default useState(true)
. When user closes dialog, we change isOpen state to false to hide dialog and navigate back to the previous parent route using useNavigate
hook.
Update file for appropriate imports and start project with npm run dev
and go to http://localhost:3000/widget
and select a widget, you should see URL change from /widget to /widget/:id and UI will display the selected widget details in a Modal Dialog on top of existing list of widgets.
Next Steps
Hopefully you now have a good idea of how to display route details in a Modal. Fundamentally, with routing, we can decide when (nested route), where (Outlet), and how to display the route (Modal).
You can even decide how to display at run time. You can let user decide how to view details, either in a modal view or on the same page in a split pane. Use Outlet context to pass an indicator and read that in child using useOutletContext
hook
// in parent
<Outlet context={{ modal: true }} />;
// in child
const { modal } = useOutletContext();
return modal ? <Dialog>UI</Dialog> : <div>UI</div>;
// in parent
<Outlet context={{ modal: true }} />;
// in child
const { modal } = useOutletContext();
return modal ? <Dialog>UI</Dialog> : <div>UI</div>;
Find Source code with a ready to run project @ GitHub
RemixFast
RemixFast auto generates List-Modal Detail route and supports host of other route patterns.