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.

Modal Route

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.

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.

By using RemixFast, you agree to our Cookie Policy.