Skip to main content

Command Palette

Search for a command to run...

How to Validate Forms with Tanstack and Zod: A Complete Guide

Published
7 min read
How to Validate Forms with Tanstack and Zod: A Complete Guide
B

Software Engineer | Technical Writer | Content Creator

Introduction

In this article, we will be experimenting with new tools like Tanstack Form, Zod, and Shadcn/UI. Tanstack Form will be used to manage the form state, Zod will handle data validation, and Shadcn/UI will be used to build the form components. Additionally, Tailwind CSS will be used for styling.

Setup

This project will be bootstrapped using Vite, make sure you have set up node.js (version >=18) and npm properly. If you are new to Vite, I suggest following the instructions in Vite documentation to quickly set up your project.

npm create vite@latest

Quick Start

To begin, you’ll need to install the necessary packages. Open your terminal and execute the following command:


npm i @tanstack/react-form
npm i @tanstack/zod-form-adapter zod
npm install zod
npm install -D tailwindcss
npx tailwindcss init
npx shadcn-ui@latest init
npx shadcn-ui@latest add button
npx shadcn-ui@latest add card
npx shadcn-ui@latest add input
npx shadcn-ui@latest add textarea
npm i country-state-city
npm install react-phone-input-2

In the above code, the Shadcn command installs dependencies adds the cn utility, and configures tailwind.config.js and CSS variables for the project. Additionally, the Shadcn command creates a components folder inside the src folder, where you can find the code for the components installed. Feel free to customize it to suit your requirements.

P.S. For the sake of this article we will be working with a few form fields. However, the full code can be found in my GitHub repository

Form Setup and Validation

After installing the packages mentioned above, you can proceed to initialize the form instance. Then, create form fields using the Shadcn components and validate with Zod. The following code snippet illustrates how to do this:

import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { formatNameToTitle } from "@/utils/helper";
import { TFormData } from "@/utils/types";
import type { FieldApi } from "@tanstack/react-form";
import { useForm } from "@tanstack/react-form";
import { zodValidator } from "@tanstack/zod-form-adapter";
import { format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import PhoneInput from "react-phone-input-2";
import "react-phone-input-2/lib/style.css";
import { z } from "zod";

type TFormData = {
  first_name: string;
  middle_name: string;
  last_name: string;
  phone_number: string;
  date_of_birth: Date | any;
};

const User = () => {
  const form = useForm<TFormData>({
    onSubmit: async ({ value }) => {
      const payload = {
        ...value,
        date_of_birth: format(new Date(value.date_of_birth), "MM-dd-yyyy"),
      };
      if (payload) {
        fetch("https://jsonplaceholder.typicode.com/posts", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify(payload),
        })
          .then((response) => response.json())
          .then((data) => {
            console.log("Success:", data);
            alert("Success");
          })
          .catch((error) => {
            console.error("Error:", error);
          });
      }
    },
    defaultValues: {
      first_name: "",
      middle_name: "",
      last_name: "",
      phone_number: "",
      date_of_birth: "",
    },
  });
  const FieldInfo = ({ field }: { field: FieldApi<any, any, any, any> }) => {
    return (
      <>
        {field.state.meta.touchedErrors ? (
          <small className="text-red-400 font-mono text-xs italic">
            {field.state.meta.touchedErrors}
          </small>
        ) : null}
        {field.state.meta.isValidating ? "Validating..." : null}
      </>
    );
  };
  return (
    <>
      <Card className="mx-auto w-2/3 my-16">
        <CardHeader>
          <CardTitle>Create Account</CardTitle>
          <CardDescription>
            Please provide your information below
          </CardDescription>
        </CardHeader>
        <form
          onSubmit={(e) => {
            e.preventDefault();
            e.stopPropagation();
            form.handleSubmit();
          }}
        >
          <CardContent className="grid grid-cols-2 gap-x-6 gap-y-6">
            <form.Field
              name="first_name"
              validators={{
                onChange: z
                  .string({
                    required_error: "First Name is required",
                    invalid_type_error: "First Name must be a string",
                  })
                  .trim()
                  .regex(
                    /^[A-Za-z]+$/,
                    "First Name must contain only alphabets"
                  ),
                onChangeAsync: z.string().refine(async (value) => {
                  await new Promise((resolve) => setTimeout(resolve, 1000));
                  return !value.includes("error");
                }),
              }}
              validatorAdapter={zodValidator}
              children={(field) => (
                <div className="flex flex-col space-y-2">
                  <Label htmlFor={field.name}>
                    {formatNameToTitle(field.name)}
                  </Label>
                  <Input
                    id={field.name}
                    name={field.name}
                    value={field.state.value}
                    onBlur={field.handleBlur}
                    onChange={(e) => field.handleChange(e.target.value)}
                    type="text"
                    placeholder={formatNameToTitle(field.name)}
                  />
                  <FieldInfo field={field} />
                </div>
              )}
            />
            <form.Field
              name="last_name"
              validators={{
                onChange: z
                  .string({
                    required_error: "Last Name is required",
                    invalid_type_error: "Last Name must be a string",
                  })
                  .trim()
                  .regex(
                    /^[A-Za-z]+$/,
                    "Last Name must contain only alphabets"
                  ),
                onChangeAsync: z.string().refine(async (value) => {
                  await new Promise((resolve) => setTimeout(resolve, 1000));
                  return !value.includes("error");
                }),
              }}
              validatorAdapter={zodValidator}
              children={(field) => (
                <div className="flex flex-col space-y-2">
                  <Label htmlFor={field.name}>
                    {formatNameToTitle(field.name)}
                  </Label>
                  <Input
                    id={field.name}
                    name={field.name}
                    value={field.state.value}
                    onBlur={field.handleBlur}
                    onChange={(e) => field.handleChange(e.target.value)}
                    type="text"
                    placeholder={formatNameToTitle(field.name)}
                  />
                  <FieldInfo field={field} />
                </div>
              )}
            />
            <form.Field
              name="middle_name"
              validators={{
                onChange: z
                  .string({
                    required_error: "Middle Name is required",
                    invalid_type_error: "Middle Name must be a string",
                  })
                  .trim()
                  .regex(
                    /^[A-Za-z]+$/,
                    "Middle Name must contain only alphabets"
                  ),
                onChangeAsync: z.string().refine(async (value) => {
                  await new Promise((resolve) => setTimeout(resolve, 1000));
                  return !value.includes("error");
                }),
              }}
              validatorAdapter={zodValidator}
              children={(field) => (
                <div className="flex flex-col space-y-2">
                  <Label htmlFor={field.name}>
                    {formatNameToTitle(field.name)}
                  </Label>
                  <Input
                    id={field.name}
                    name={field.name}
                    value={field.state.value}
                    onBlur={field.handleBlur}
                    onChange={(e) => field.handleChange(e.target.value)}
                    type="text"
                    placeholder={formatNameToTitle(field.name)}
                  />
                  <FieldInfo field={field} />
                </div>
              )}
            />
            <form.Field
              name="phone_number"
              validators={{
                onChange: z
                  .string({
                    required_error: "Phone number is required",
                    invalid_type_error: "Phone number must be a string",
                  })
                  .trim()
                  .regex(
                    /^((\\+[1-11]{1,4}[ \\-]*)|(\\([0-9]{2,3}\\)[ \\-]*)|([0-9]{2,4})[ \\-]*)*?[0-9]{3,4}?[ \\-]*[0-9]{3,4}?$/,
                    "Phone number must be a valid number"
                  ),
                onChangeAsync: z.string().refine(async (value) => {
                  await new Promise((resolve) => setTimeout(resolve, 1000));
                  return !value.includes("error");
                }),
              }}
              validatorAdapter={zodValidator}
              children={(field) => (
                <div className="flex flex-col space-y-2">
                  <Label htmlFor={field.name}>
                    {formatNameToTitle(field.name)}
                  </Label>
                  <PhoneInput
                    country={"ng"}
                    value={field.state.value}
                    onChange={(phone) => field.handleChange(phone)}
                    onBlur={field.handleBlur}
                  />
                  <FieldInfo field={field} />
                </div>
              )}
            />
            <form.Field
              name="date_of_birth"
              mode="value"
              validators={{
                onChange: z
                  .date({
                    required_error: "Please select a date of birth",
                    invalid_type_error: "Date of birth must be a valid date",
                    message: "Please select a date and time",
                  })
                  .refine(
                    (date) => date instanceof Date && !isNaN(date.getTime()),
                    "Date of birth must be a valid date"
                  ),
                onChangeAsync: z.date().refine(
                  async (value) => {
                    await new Promise((resolve) => setTimeout(resolve, 1000));
                    return !(value && isNaN(value.getTime()));
                  },
                  { message: "Date of birth must be a valid date" }
                ),
              }}
              validatorAdapter={zodValidator}
              children={(field) => (
                <div className="flex flex-col space-y-2">
                  <Label htmlFor={field.name}>
                    {formatNameToTitle(field.name)}
                  </Label>
                  <Popover>
                    <PopoverTrigger asChild>
                      <Button
                        variant={"outline"}
                        className={cn(
                          "w-[280px] justify-start text-left font-normal",
                          !field.state.value && "text-muted-foreground"
                        )}
                      >
                        <CalendarIcon className="mr-2 h-4 w-4" />
                        {field.state.value ? (
                          format(new Date(field.state.value), "PPP")
                        ) : (
                          <span>Pick a date</span>
                        )}
                      </Button>
                    </PopoverTrigger>
                    <PopoverContent className="w-auto p-0">
                      <Calendar
                        mode="single"
                        selected={field.state.value}
                        onSelect={(value: any) => field.handleChange(value)}
                        initialFocus
                      />
                    </PopoverContent>
                  </Popover>
                  <FieldInfo field={field} />
                </div>
              )}
            />
          </CardContent>
          <CardFooter className="flex">
            <form.Subscribe
              selector={(state) => [state.canSubmit, state.isSubmitting]}
              children={([canSubmit, isSubmitting]) => (
                <Button type="submit" disabled={!canSubmit}>
                  {isSubmitting ? "..." : "Submit"}
                </Button>
              )}
            />
          </CardFooter>
        </form>
      </Card>
    </>
  );
};
export default User;

In the above code, the Card component from the Shadcn UI library is used to create a container for the form fields.

In Tanstack Form, validation can be handled at the field level or at the form level. In this article, we will handle validation at the field level, with validation occurring at each keystroke.

We created a form instance using the useForm hook provided by Tanstack, which provides methods and properties for us to work with.

  1. onSubmit: This function will be called when the form is submitted. Inside this function, we will be making the async calls.

  2. defaultValues: This is an object that contains all initial values of each form field.

The validatorAdapter enables compatibility with the Zod schema validation library, and it’s passed as props to each form field component

form.Subscribe method is used to subscribe to the form state. This helps in managing form rendering and only updates the components when necessary.

The canSubmit flag in the form state is used to control whether the form can be submitted. It returns a boolean value: true when all fields are valid and false when any field is invalid and the form has been interacted with (touched). This flag typically determines whether to enable or disable the submit button based on the form’s validation status.

The isSubmitting flag in the form state is used to track the submission state of the form. It returns a boolean value that indicates whether the form is currently being submitted. Specifically, it returns true when the form submission process is ongoing, and false otherwise.

The FieldInfo reusable component was created to render an error message for each field when it undergoes validation.

Conclusion

In this article, we have successfully:

  1. set up the Vite app for our project.

  2. Set up Tanstack form.

  3. Managed the form state using Tansatck form.

  4. Validated form fields using Zod.

  5. Built Form components using Shadcn UI library.

  6. Submitted form data to the backend server.

If you like my content you can connect with me on LinkedIn and Twitter.

Thanks for reading.

More from this blog