Fullstack Type-Safe Forms With React, Remix, Remix Validated Form, and Zod

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. 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 it 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:

In the below screenshot, I have submitted the form without some of the fields filled out. You’ll notice 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.

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(),
    });
    
    type CreatePostType = z.infer<typeof createPostSchema>

    The resulting type:

    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 types, 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.

    Zod

    https://www.npmjs.com/package/zod

    https://zod.dev/

    Remix Validated Form, @remix-validated-form/with-zod, zod-form-data

    Source code: