Modals with Remix
Using Remix I have found it second nature to use modal routes when I can. Instead of using useState
and passing data to the modal component, a modal route can be created under the parent route, and it being a route there is no need to pass data that may not be used if a user chooses not to open the modal. Less mess, more simplicity.
Since it is a route, you can use loader as well action, I have found that for most cases useState
is unnecessary, and more messy when using Remix.
In this post I'll create a modal route that creates a user entry in the database to portray how simple and effortless using Remix can be. Much less time thinking about how to do something, more time spent on doing what is important.
First we'll initialize Prisma by creating a file called db.server.ts
// app/db.server.ts
import { PrismaClient } from "@prisma/client"
declare global {
var __prisma: PrismaClient
}
if (!global.__prisma) {
global.__prisma = new PrismaClient()
}
global.__prisma.$connect()
export const prisma = global.__prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Post {
id Int @id @default(autoincrement())
title String
name String
date_created DateTime @default(now())
date_updated DateTime @default(now())
}
We'll also create a route file called posts.tsx
, we'll render the posts from the database here, we use a loader to pass the data from the database to the user:
import type {LoaderFunctionArgs, MetaFunction} from "@remix-run/node";
import {prisma} from "~/db.server";
import {Link, Outlet, useLoaderData} from "@remix-run/react";
export const meta: MetaFunction = () => {
return [
{ title: "New Remix App" },
{ name: "description", content: "Welcome to Remix!" },
];
};
export default function Index() {
const {posts} = useLoaderData();
return (
<div className={'w-[80%] m-auto p-6'}>
<nav className={'mb-6'}>
<h1 className={'text-2xl font-medium'}>Posts</h1>
<Link to={'/posts/create'}>
<button className={'bg-blue-600 px-5 py-1.5 text-white text-sm text-center rounded-[8px]'}>Create</button>
</Link>
</nav>
<div >
<table className={'text-left'}>
<thead >
<tr>
<th scope="col" >
Post ID
</th>
<th scope="col">
Title
</th>
<th scope="col" >
Name
</th>
<th scope="col" >
Last updated
</th>
<th scope="col" >
Actions
</th>
</tr>
</thead>
<tbody>
{posts.map(post => <tr >
<th scope="row" >
{post.id}
</th>
<td>
{post.title}
</td>
<td >
{post.name}
</td>
<td >
{post.date_updated}
</td>
<td className={'text-left'} >
<Link className={'text-left'} to={`/posts/${post.id}`}>
Edit
</Link>
</td>
</tr>)}
</tbody>
</table>
</div>
<Outlet/>
</div>
);
}
export async function loader({request}: LoaderFunctionArgs) {
const posts = await prisma.post.findMany()
return {posts}
}
}
export async function loader({request}: LoaderFunctionArgs) {
const posts = await prisma.post.findMany()
return {posts}
}
This will render all the posts from the database, first we will deal with the first modal route that creates a post in the database.
First we must create a file posts.create.tsx
, but naming a file like that is not enough for the modal to appear on top of posts, an <Outlet/>
must exist inside the posts.tsx
route.
// posts.create.tsx
import {useEffect} from "react";
import {Form, Link, redirect} from "@remix-run/react";
import {ActionFunctionArgs} from "@remix-run/node";
import {z} from "zod";
import {zx} from "zodix";
import {prisma} from "~/db.server";
export default function Create() {
return <div id="default-modal" tabIndex="-1" aria-hidden="true"
className="bg-black bg-opacity-60 overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] h-full">
<div className="relative p-4 w-full m-auto mt-[7%] max-w-2xl max-h-full">
<div className="relative bg-white rounded-lg shadow">
<Form method={'post'}>
<div className="flex items-center justify-between p-4 md:p-5 border-b rounded-t">
<h3 className="text-xl font-semibold text-gray-900 ">
Create post
</h3>
<Link to={'/posts'}>
<button type="button"
className="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-11 h-11 ms-auto inline-flex justify-center items-center"
data-modal-hide="default-modal">
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
<span className="sr-only">Close modal</span>
</button>
</Link>
</div>
<div className="p-4 md:p-5 space-y-4">
<div>
<label htmlFor="title"
className="block mb-2 text-sm font-medium text-gray-900">Title</label>
<input type="text" name={'title'}
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 "
placeholder="Enter a title here" required/>
</div>
</div>
<div className={'flex justify-between items-center'}>
<div>
</div>
<div className="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b">
<button data-modal-hide="default-modal" type="submit"
className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center">Submit
</button>
<Link to={'/posts'}>
<button data-modal-hide="default-modal" type="button"
className="py-2.5 px-5 ms-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100">Cancel
</button>
</Link>
</div>
</div>
</Form>
</div>
</div>
</div>
}
You will notice as it is often with Remix that there is no JS here, closing the modal simply takes you back to /posts which is the parent route.
Now we will add an action which will create a new post when the user submits the form. We will validate the request body with Zodix, a module that allows us to use Zod to parse FormData.
function slugify(str: string) {
str = str.replace(/^\s+|\s+$/g, ''); // trim leading/trailing white space
str = str.toLowerCase(); // convert string to lowercase
str = str.replace(/[^a-z0-9 -]/g, '') // remove any non-alphanumeric characters
.replace(/\s+/g, '-') // replace spaces with hyphens
.replace(/-+/g, '-'); // remove consecutive hyphens
return str;
}
export async function action({request}: ActionFunctionArgs) {
const { title } = await zx.parseForm(request, {
title: z.string(),
});
const slug = slugify(title);
await prisma.post.create({
data: {
title,
name: slug
}
})
return redirect("/posts?created=true")
}
We will redirect the user to /posts with a URL param that will let the user know that the post has been created successfully, that is often times achieved with session.flash but as this is a simple example this suffices.
// app/root.tsx
const [searchParams, setSearchParams] = useSearchParams()
useEffect(() => {
if (searchParams.get('created')) {
alert("Post created")
const params = new URLSearchParams();
params.delete("created");
setSearchParams(params, {
preventScrollReset: true,
});
}
}, [searchParams])
And the result is simplicity of a modal route, no need to have a state to manage the modal, or an API route to handle the creation of a post. The code is more clean and organized, and with a modal route like posts.create.tsx
the action is found in that file as well, and it handles the task of creating a post entirely.
A modal route has many different possibilities, user settings, editing posts, creating posts. And that is the appeal of a modal route, simplicity in code and less clutter.
You can find the source code here: https://github.com/ddm50/modals-with-remix
Top comments (0)