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.
1import { useFetcher } from "remix";23export default function CreatePost() {4const fetcher = useFetcher();56return (7<fetcher.Form8action="/posts" //can be ignored if the action is the same as the current URL9method="post"10className="space-y-4"11noValidate12>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.
1import { json, redirect } from "remix";23export async function action({ request }) {4const formData = await request.formData();5const title = formData.get("title");6const body = formData.get("body");78if (!title) {9return json({ error: "Title is required" }, { status: 400 });10}1112if (!body) {13return json({ error: "Body is required" }, { status: 400 });14}1516// create a new post17await db.posts.create({18data: {19title,20body,21},22});2324return 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.
1import { useFetcher } from "remix";23export default function CreatePost() {4const fetcher = useFetcher();56const isSubmitting = fetcher.state === "submitting" // this is true when the form is submitting78return (9<fetcher.Form10action="/posts" //can be ignored if the action is the same as the current URL11method="post"12className="space-y-4"13noValidate14>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 submitting25</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.
1import { useFetcher } from "remix";2import { z } from "zod";3import {ActionFunctionArgs} from "@remix-run/router";4import {json,redirect} from "@remix-run/node";56const schema = z.object({7title: z.string().min(3).max(50),8body: z.string().min(3).max(500),9});1011async function action({request}: ActionFunctionArgs) {12const formData = await request.formData();13const data = await schema.parseAsync(Object.fromEntries(formData));1415if (!data.title) {16return json({ error: "Title is required" }, { status: 400 });17}1819// create a new post20await db.posts.create({21data,22});2324return redirect("/posts");25}2627function CreatePost() {28const fetcher = useFetcher();2930const hasError = fetcher.data?.error // this is true when the form has an error3132return (33<fetcher.Form34action="/posts" //can be ignored if the action is the same as the current URL35method="post"36className="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 error42</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.
1import { useFetcher } from "remix";2import { ActionFunctionArgs } from "@remix-run/router";3import {json,redirect} from "@remix-run/node";4import {useLoaderData} from "@remix-run/react";56async function loader() {7const posts = await db.posts.findMany({8orderBy: {9createdAt: "desc",10},11});1213return json(posts);14}1516async function action({ request }: ActionFunctionArgs) {17const formData = await request.formData();18const id = formData.get("id");1920if (!id) {21return json({ error: "Id is required" }, { status: 400 });22}2324// delete a post25await db.posts.delete({26where: {27id28},29});3031return redirect("/posts");32}3334export default const Posts = () => {35const data = useLoaderData<typeof loader>();36const fetcher = useFetcher();3738return (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 server46<button type="submit">47Delete48</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.
1import { useFetcher } from "remix";2import { ActionFunctionArgs } from "@remix-run/router";3import {json} from "@remix-run/node";4import {useLoaderData} from "@remix-run/react";56async function loader() {7const posts = await db.posts.findMany({8orderBy: {9createdAt: "desc",10},11});1213return json(posts);14}1516async function action({ request }: ActionFunctionArgs) {17const formData = await request.formData();18const id = formData.get("id");1920if (!id) {21return json({ error: "Id is required" }, { status: 400 });22}2324// we can use try catch to catch the error to revert the changes25try {26// delete a post27await db.posts.delete({28where: {29id30},31});32} catch (error) {33return json({ error: error.message }, { status: 500 });34}35}3637export default const Posts = () => {38const data = useLoaderData<typeof loader>();39const fetcher = useFetcher();4041const isDeleting = fetcher.formData.get('id') // check if the form is submitting and the id is present42const isFailed = fetcher.data?.error4344return (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 present50<h2>{post.title}</h2>51<input type="hidden" name="id" value={post.id} /> // we need to send the id of the post to the server52<button type="submit">53Delete54</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.
1import { useFetcher } from "remix";2import {json} from "@remix-run/node";3import {useLoaderData} from "@remix-run/react";45async function loader() {6const posts = await db.posts.findMany({7orderBy: {8createdAt: "desc",9},10});1112return json(posts);13}1415export default const Posts = () => {16const data = useLoaderData<typeof loader>();17const fetcher = useFetcher();1819const isDeleting = fetcher.formData.get('id') // check if the form is submitting and the id is present20const isFailed = fetcher.data?.error2122// fetcher presents us with23const hasTitle = fetcher.formData.get('title') // check if the form is submitting and the title is present2425return (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 present31<h2>{post.title}</h2>32<input type="hidden" name="id" value={post.id} /> // we need to send the id of the post to the server33<button type="submit">34Delete35</button>36</li>37))}38// we add this section when the form is submitting and the title is present39{40hasTitle && (41<li>42<h2>{fetcher.formData.get('title')}</h2>43<button type="submit">44Delete45</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.