@sudocode_

Forms with useFetcher and Zod in Remix

July 1, 2023 / 8 min read

Last Updated: July 1, 2023

Forms are an essential part of many web applications, enabling users to interact with the application by submitting data. However, handling form submission and validation can be challenging. In this blog post, we will explore how to build forms using the useFetcher hook and Zod validation library in Remix, a versatile framework for building web applications. Combining these powerful tools allows for efficient form handling and validation, ensuring a smooth user experience. Let's dive in!

Prerequisites:

Before getting started, make sure you have a basic understanding of Remix. If you are new to Remix, check out the official documentation and egghead course to get up to speed.

Working with Forms in Remix is a breeze. The framework provides a Form component that handles form submission and validation. However, the Form component is not the only way to handle forms in Remix. In this blog post, we will explore how to build forms using the useFetcher hook and Zod validation library.

One of the primary features of Remix is simplifying interactions with the server to get data into components. The useFetcher hook allows us to call a loader outside its navigation, or call an action but doesn't change the URL.

Let's start building a form to create a new blog post. We will use the useFetcher hook to call an action to create a new blog post.

1
import { useFetcher } from "remix";
2
3
export default function CreatePost() {
4
const fetcher = useFetcher();
5
6
return (
7
<fetcher.Form
8
action="/posts" //can be ignored if the action is the same as the current URL
9
method="post"
10
className="space-y-4"
11
noValidate
12
>
13
<div>
14
<label htmlFor="title">Title</label>
15
<input id="title" name="title" type="text" />
16
</div>
17
<div>
18
<label htmlFor="body">Body</label>
19
<textarea id="body" name="body" />
20
</div>
21
<button type="submit">Create Post</button>
22
</fetcher.Form>
23
);
24
}

Now, we can get the data from the form submission in the action handler.

1
import { json, redirect } from "remix";
2
3
export async function action({ request }) {
4
const formData = await request.formData();
5
const title = formData.get("title");
6
const body = formData.get("body");
7
8
if (!title) {
9
return json({ error: "Title is required" }, { status: 400 });
10
}
11
12
if (!body) {
13
return json({ error: "Body is required" }, { status: 400 });
14
}
15
16
// create a new post
17
await db.posts.create({
18
data: {
19
title,
20
body,
21
},
22
});
23
24
return redirect("/posts");
25
}

Pending UI

This approach seems to work fine but for a better user experience, let's find a way to show the user that we are submitting the data to the server.

1
import { useFetcher } from "remix";
2
3
export default function CreatePost() {
4
const fetcher = useFetcher();
5
6
const isSubmitting = fetcher.state === "submitting" // this is true when the form is submitting
7
8
return (
9
<fetcher.Form
10
action="/posts" //can be ignored if the action is the same as the current URL
11
method="post"
12
className="space-y-4"
13
noValidate
14
>
15
<div>
16
<label htmlFor="title">Title</label>
17
<input id="title" name="title" type="text" />
18
</div>
19
<div>
20
<label htmlFor="body">Body</label>
21
<textarea id="body" name="body" />
22
</div>
23
<button type="submit">
24
{isSubmitting ? "Submitting" : "Create Post" } // show submitting when the form is submitting
25
</button>
26
</fetcher.Form>
27
);
28
}

Zod Validation

Now, let's add some validation to the form. We will use the Zod validation library to validate the form data.

1
import { useFetcher } from "remix";
2
import { z } from "zod";
3
import {ActionFunctionArgs} from "@remix-run/router";
4
import {json,redirect} from "@remix-run/node";
5
6
const schema = z.object({
7
title: z.string().min(3).max(50),
8
body: z.string().min(3).max(500),
9
});
10
11
async function action({request}: ActionFunctionArgs) {
12
const formData = await request.formData();
13
const data = await schema.parseAsync(Object.fromEntries(formData));
14
15
if (!data.title) {
16
return json({ error: "Title is required" }, { status: 400 });
17
}
18
19
// create a new post
20
await db.posts.create({
21
data,
22
});
23
24
return redirect("/posts");
25
}
26
27
function CreatePost() {
28
const fetcher = useFetcher();
29
30
const hasError = fetcher.data?.error // this is true when the form has an error
31
32
return (
33
<fetcher.Form
34
action="/posts" //can be ignored if the action is the same as the current URL
35
method="post"
36
className="space-y-4"
37
>
38
<div>
39
<label htmlFor="title">Title</label>
40
<input id="title" name="title" type="text" />
41
{hasError && <p className="text-red-500">{fetcher.data.error}</p>} // show the error message if the form has an error
42
</div>
43
<div>
44
<label htmlFor="body">Body</label>
45
<textarea id="body" name="body" />
46
</div>
47
<button type="submit">Create Post</button>
48
</fetcher.Form>
49
);
50
}

We can send data to the server, let's fetch the data from the server and display it on the page. Also, we can implement deleting a post.

1
import { useFetcher } from "remix";
2
import { ActionFunctionArgs } from "@remix-run/router";
3
import {json,redirect} from "@remix-run/node";
4
import {useLoaderData} from "@remix-run/react";
5
6
async function loader() {
7
const posts = await db.posts.findMany({
8
orderBy: {
9
createdAt: "desc",
10
},
11
});
12
13
return json(posts);
14
}
15
16
async function action({ request }: ActionFunctionArgs) {
17
const formData = await request.formData();
18
const id = formData.get("id");
19
20
if (!id) {
21
return json({ error: "Id is required" }, { status: 400 });
22
}
23
24
// delete a post
25
await db.posts.delete({
26
where: {
27
id
28
},
29
});
30
31
return redirect("/posts");
32
}
33
34
export default const Posts = () => {
35
const data = useLoaderData<typeof loader>();
36
const fetcher = useFetcher();
37
38
return (
39
<fetcher.Form>
40
<h1>Posts</h1>
41
<ul>
42
{data.map((post) => (
43
<li key={post.id}>
44
<h2>{post.title}</h2>
45
<input type="hidden" name="id" value={post.id} /> // we need to send the id of the post to the server
46
<button type="submit">
47
Delete
48
</button>
49
</li>
50
))}
51
</ul>
52
<CreatePost />
53
</fetcher.Form>
54
);
55
};

Optimistic UI

Deleting a post works fine but what if we want to delete multiple posts at once. This is where optimistic UI comes in. We can delete the post from the UI before sending the request to the server. If the request fails, we can revert the changes.

1
import { useFetcher } from "remix";
2
import { ActionFunctionArgs } from "@remix-run/router";
3
import {json} from "@remix-run/node";
4
import {useLoaderData} from "@remix-run/react";
5
6
async function loader() {
7
const posts = await db.posts.findMany({
8
orderBy: {
9
createdAt: "desc",
10
},
11
});
12
13
return json(posts);
14
}
15
16
async function action({ request }: ActionFunctionArgs) {
17
const formData = await request.formData();
18
const id = formData.get("id");
19
20
if (!id) {
21
return json({ error: "Id is required" }, { status: 400 });
22
}
23
24
// we can use try catch to catch the error to revert the changes
25
try {
26
// delete a post
27
await db.posts.delete({
28
where: {
29
id
30
},
31
});
32
} catch (error) {
33
return json({ error: error.message }, { status: 500 });
34
}
35
}
36
37
export default const Posts = () => {
38
const data = useLoaderData<typeof loader>();
39
const fetcher = useFetcher();
40
41
const isDeleting = fetcher.formData.get('id') // check if the form is submitting and the id is present
42
const isFailed = fetcher.data?.error
43
44
return (
45
<fetcher.Form>
46
<h1>Posts</h1>
47
<ul>
48
{data.map((post) => (
49
<li key={post.id} hidden={isDeleting === post.id} color={isFailed ? 'red': ""}> // hide the post if the form is submitting and the id is present
50
<h2>{post.title}</h2>
51
<input type="hidden" name="id" value={post.id} /> // we need to send the id of the post to the server
52
<button type="submit">
53
Delete
54
</button>
55
</li>
56
))}
57
</ul>
58
<CreatePost />
59
</fetcher.Form>
60
);
61
};

We added try catch to the action to catch any errors and revert the changes back to the UI if deletion fails. And we checked in the UI if the data submitted has the id, and if it does, we hide the post.

Now, let's do same for creating a post.

1
import { useFetcher } from "remix";
2
import {json} from "@remix-run/node";
3
import {useLoaderData} from "@remix-run/react";
4
5
async function loader() {
6
const posts = await db.posts.findMany({
7
orderBy: {
8
createdAt: "desc",
9
},
10
});
11
12
return json(posts);
13
}
14
15
export default const Posts = () => {
16
const data = useLoaderData<typeof loader>();
17
const fetcher = useFetcher();
18
19
const isDeleting = fetcher.formData.get('id') // check if the form is submitting and the id is present
20
const isFailed = fetcher.data?.error
21
22
// fetcher presents us with
23
const hasTitle = fetcher.formData.get('title') // check if the form is submitting and the title is present
24
25
return (
26
<fetcher.Form>
27
<h1>Posts</h1>
28
<ul>
29
{data.map((post) => (
30
<li key={post.id} hidden={isDeleting === post.id} color={isFailed ? 'red': ""}> // hide the post if the form is submitting and the id is present
31
<h2>{post.title}</h2>
32
<input type="hidden" name="id" value={post.id} /> // we need to send the id of the post to the server
33
<button type="submit">
34
Delete
35
</button>
36
</li>
37
))}
38
// we add this section when the form is submitting and the title is present
39
{
40
hasTitle && (
41
<li>
42
<h2>{fetcher.formData.get('title')}</h2>
43
<button type="submit">
44
Delete
45
</button>
46
</li>
47
)
48
}
49
</ul>
50
<CreatePost />
51
</fetcher.Form>
52
);
53
};

We added a section to the UI to show the post when the form is submitting and the title is present.

There's a lot you can do with useFetcher. You can check out the docs for more information.

Conclusion

In this tutorial, we learned how to use useFetcher hook to create a form that submits data to the server and how to use optimistic UI to make the user experience better.

Liked this article? Share it with a friend on Twitter or contact me let's start a new project. Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I'll always be available to respond to your messages.

Have a wonderful day.

– Felix

Forms are an essential part of many web applications, enabling users to interact with the application by submitting data.