React, Remix, Remix Validated Form, and Zod: The Ultimate Stack for Type-Safe Forms

Using Remix, Remix Validated Form, Zod, and Zod Form Data, developers can validate forms, easily handle and show error states, prevent false submissions, improve the developer experience, and increase developer velocity.

Brandon Schabel
Nookit Dev
8 min readDec 18, 2022

--

full-stack of forms with a react logo on top

Remix takes advantage of the web platform APIs, which makes it easy to create forms that submit data to an action that passes the form data to the server.

Zod provides a way to validate data on both the client and server-side, ensuring accuracy and security when handling user data. Zod Form Data further simplifies the process by providing a convenient way to map data from a form to a Zod type.

You’ll find the source code and a link to all the packages at the bottom of the post. Another awesome thing about this combination is a lot of functionality still works even if you were to disable JavaScript since validation is handled client and server-side.

Zod is a JavaScript library that provides a powerful, type-safe way to define, validate, and manipulate data. It uses a declarative syntax similar to TypeScript, making it easy to create type-safe forms with React.

The library provides a way to validate data on both the client and server side, ensuring accuracy and security when handling user data. Zod Form Data further simplifies the process by providing a convenient way to map data from a form to a Zod type.

The first step in creating a type-safe form is to create a submit button component. This submit button can be handled automatically using the useIsSubmittingHook from remix-validated-form. This hook will allow the form to automatically handle the status of the form submission to prevent double submissions.

import { useIsSubmitting } from "remix-validated-form";

export const SubmitButton = ({
submitText = "Submit",
}: {
submitText?: string;
}) => {
const isSubmitting = useIsSubmitting();

return (
<button
type="submit"
disabled={isSubmitting}
className="bg-black text-white p-3 rounded-md"
>
{isSubmitting ? "Submitting..." : submitText}
</button>
);
};

Next, we need to create an input field that uses the useField hook from remix-validated-form. This hook has a few useful features such as error feedback. We can use this to display visual feedback of which fields are giving us errors as well as display that error to the user. In addition, we use the clearError function when the input is clicked to clear the error.

import classNames from "classnames";
import { useField } from "remix-validated-form";

export const Input = ({
name,
title,
id,
}: {
name: string;
title?: string;
id?: string;
}) => {
const field = useField(name);
return (
<div className={"flex flex-col w-full"}>
<label htmlFor={name}>{title}</label>
<input
{...field.getInputProps()}
className={classNames("border-2 rounded-md", {
"border-2 !border-red-500": field.error,
})}
name={name}
id={id ? id : name}
onClick={() => {
field.clearError();
}}
onChange={() => {
if (field.error) field.clearError();
}}
/>
<div className="text-red-500">{field.error}</div>
</div>
);
};

In the below code, we create, a schema, we create a validator based on that schema, then we create a client-side form where we pass the schema, the form will then validate the form on both the client and server against that validator.

Then once we submit that form data to the server it will validate that data against the validator and then we can pull the validated data.

import { ActionArgs } from "@remix-run/node";
import { withZod } from "@remix-validated-form/with-zod";
import { ValidatedForm, validationError } from "remix-validated-form";
import { z } from "zod";
import { zfd } from "zod-form-data";
import { Input } from "~/components/input";
import { SubmitButton } from "~/components/submit-button";

const createPostSchema = zfd.formData({
// zfd(zod form data) is a helper that helps to parse the form data to an object
// using the zod schema, if there are multiple values with the same name an array will be returned.
// it can handle URLSearchParams, FormData, and plain objects
title: zfd.text(z.string().min(1).max(100)),
author: zfd.text(z.string().min(1).max(50)),
content: zfd.text(z.string().min(1).max(1000)),
published: zfd.checkbox(),
});
export type CreatePostType = z.infer<typeof createPostSchema>;
// remix-validated-form with-zod is a helper that helps to validate form data
// remix-validated-form supported custom validation and other libraries like yup
const createPostValidator = withZod(createPostSchema);
export async function action({ request }: ActionArgs) {
const formData = await request.formData();
const validation = await createPostValidator.validate(formData);
// if there are any errors, return validationError, this is also handled
// by remix-validated-form
if (validation.error) {
return validationError(validation.error);
}
// if we make it here, we know that there are no errors so we can
// get the data from the validation object
const { title, content, author, published } = validation.data;
console.log("Creating Post...", { title, content, author, published });
}
export default function () {
return (
<div className="flex items-center justify-center">
{/* Validated form will validate form on both the server side and client side
form will not submit to server if there are any errors.*/}
<ValidatedForm
validator={createPostValidator}
className="flex flex-col space-y-4 w-10/12 lg:w-1/2"
method="post"
>
<Input name="title" title="Post Title" />
<Input name="author" title="Author" />
<Input name="content" title="Post Content" />
<div className="flex flex-row items-center">
<label htmlFor="publish">Publish</label>
<input
type="checkbox"
id="publish"
name="publish"
className="ml-2 h-5 w-5"
/>
</div>
<div className="w-full flex justify-center items-center">
<SubmitButton submitText="Create Post" />
</div>
</ValidatedForm>
</div>
);
}

Here is what our form looks like:

form before submission

In the below screenshot, I have submitted the form without some of the fields filled out. You’ll notice the author field has focus styles, the nice thing about remix-validated-form is it will automatically focus on the first field that errors. When you click on a field or start typing, you’ll notice the errors are cleared.

form post submission

Breaking It Down

Let’s break down each piece from the file above, we can break it down into 3 pieces in the order that everything happens:

  • The form/zod validator
  • The form which will submit to the server
  • Then the server-side action that will handle the form data

The zod-form-data library provides validation helpers for Zod specifically for parsing FormData or URLSearchParams, which is particularly useful when using remix and remix-validated-form. It simplifies the process of validating form data by allowing users to write their types closer to how they want to.

const createPostSchema = zfd.formData({
title: zfd.text(z.string().min(1).max(100)),
author: zfd.text(z.string().min(1).max(50)),
content: zfd.text(z.string().min(1).max(1000)),
published: zfd.checkbox(),
});

const createPostValidator = withZod(createPostSchema);

All we need to do is use the ValidatedForm from the remix-validated-form library. Functionally it is very similar to the Remix Form component with the addition of the validator, of course, there is magically going on under the hood, and I’d encourage you to read their docs.

We are also using several Input components that we made that include error handling, as well as a checkbox, and a SubmitButton component. When the user fills outs the form, first on submission the form will be validated on the client, if it fails then we will see the error states in the inputs, however, if it succeeds then that form data is passed to the server where the server will then validate the form data using that same validation schema.

Of course, it’s useful to validate on the client and server because of a person were to try to maliciously submit false form data to the server and you don’t do proper validation on the server then you could run into some issues where you get data you don’t want in your database.

The best part about using Remix which uses serverside and Remix Validated Form doing client and server-side validation, even if you were to disable JavaScript, we would still see the fields error on submit because the action will return the errors and Remix SSRs the page with the action data! Of course, when JavaScript is enabled Remix doesn’t need to reload the whole page to get that same result.

export default function () {
return (
<div className="flex items-center justify-center
<ValidatedForm
validator={createPostValidator}
className="flex flex-col space-y-4 w-10/12 lg:w-1/2"
method="post"
>
<Input name="title" title="Post Title" />

<Input name="author" title="Author" />

<Input name="content" title="Post Content" />

<div className="flex flex-row items-center">
<label htmlFor="publish">Publish</label>
<input
type="checkbox"
id="publish"
name="publish"
className="ml-2 h-5 w-5"
/>
</div>

<div className="w-full flex justify-center items-center">
<SubmitButton submitText="Create Post" />
</div>
</ValidatedForm>
</div>
);
}

When the form is submitted the action is run on the server side, here we get the form data from the request. We pass that form data to the validator which will check each field against the validation for each field we define. If there is an error then we respond with validationError and that will be handled on the client side. If we get passed the error step we can be sure that we have valid data. Here I’m just destructuring the data to demonstrate that we indeed have valid data with no type error and console logging that data. From here generally, you would want to then insert that data into your database or whatever use case you may have.

export async function action({ request }: ActionArgs) {
const formData = await request.formData();

const validation = await createPostValidator.validate(formData);

if (validation.error) {
return validationError(validation.error);
}

const { title, content, author, published } = validation.data;

console.log("Creating Post...", { title, content, author, published });
}

Below we are using Zod’s infer function, this is an incredibly useful utility for example if you want to create a function where you insert data into your database you can infer the types for the inputs to the function just from the form schema. Below is an example of the CreatePostType

const createPostSchema = zfd.formData({
title: zfd.text(z.string().min(1).max(100)),
author: zfd.text(z.string().min(1).max(50)),
content: zfd.text(z.string().min(1).max(1000)),
published: zfd.checkbox(),
});

The resulting type:

z.infer magic!

Getting forms right and providing a positive user experience is always a challenge, I’ve been playing around with Zod and these libraries for a few months now and I have not found a more simple type, validation, and robust solution than these libraries. I’m also sure I am just barely scratching the surface of what you can do with these libraries.

Shoutout to the packages I used, please check them out and read their docs:

Source code

--

--

Brandon Schabel
Nookit Dev

Previously SWE at Stats Perform. Open Source contributor who writes about my work - exploring new tech like Bun and developing Bun Nook Kit.