DEV Community

Md. Maruf Rahman
Md. Maruf Rahman

Posted on

React Hook Form + Zod: Complete Guide to Type-Safe Form Validation in React

React Hook Form with Zod Validation: The Complete Guide You've Been Looking For

Master form validation in React with this powerful combination that will save you hours of debugging


If you've ever struggled with form validation in React, you're not alone. I've been there too—dealing with messy validation logic, inconsistent error messages, and the constant battle between type safety and runtime validation. That's when I discovered the perfect duo: React Hook Form and Zod.

After implementing this combination in multiple production projects, I can confidently say it's a game-changer. In this guide, I'll walk you through everything you need to know to implement robust, type-safe form validation that actually makes sense.

Want to see this in action? Check out the full interactive version on my website with live code examples and additional resources.


Why React Hook Form + Zod?

Before we dive into the code, let me explain why this combination is so powerful:

  • React Hook Form: Minimal re-renders, excellent performance, and a simple API
  • Zod: TypeScript-first schema validation with incredible type inference
  • Together: Type-safe forms that validate at runtime AND compile time

The best part? You get all the benefits of TypeScript's type checking while ensuring your data is actually valid when it reaches your backend.


Getting Started: Installation

First things first, let's install the necessary packages:

npm install react-hook-form @hookform/resolvers zod
Enter fullscreen mode Exit fullscreen mode

That's it! Just three packages, and you're ready to build forms that would make your past self jealous.


Building Your First Schema

Let's create a real-world example: a product form. This isn't just a "hello world" tutorial—we're building something you'd actually use in production.

Here's how we define our validation schema with Zod:

import * as z from "zod";

const productSchema = z.object({
  name: z
    .string()
    .trim()
    .min(1, { message: "Required" })
    .min(2, { message: "Minimum 2 characters required" }),
  categoryId: z.string().trim().min(1, { message: "Required" }),
  sku: z.string().trim(),
  description: z.string().trim(),
  price: z.string().trim().min(1, { message: "Required" }),
  cost: z.string().trim(),
  stock: z.string().trim().min(1, { message: "Required" }),
  minStock: z.string().trim(),
  unit: z.string().trim(),
  barcode: z.string().trim(),
  product_image: z.preprocess((val) => {
    if (!val) return null;
    if (val instanceof FileList) {
      const file = val.item(0);
      return file ?? null;
    }
    return val;
  }, z.instanceof(File).nullable().refine(
    (file) => file !== null, 
    { message: "Product image is required" }
  )),
  product_gallery: z.preprocess((val) => {
    if (!val) return null;
    if (val instanceof FileList) {
      return Array.from(val);
    }
    return val;
  }, z.array(z.instanceof(File)).optional().nullable()),
});

type ProductFormData = z.infer<typeof productSchema>;
Enter fullscreen mode Exit fullscreen mode

Notice something cool here? That z.infer<typeof productSchema> automatically generates the TypeScript type for you. No manual type definitions needed—Zod does the heavy lifting.

The preprocess function is particularly useful for handling file inputs, which come as FileList objects from the DOM but we want to work with File objects in our validation.


Setting Up React Hook Form

Now let's wire everything together. This is where the magic happens:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

function AddProduct() {
  const {
    register,
    handleSubmit,
    watch,
    control,
    formState: { errors, isSubmitting, isValid },
  } = useForm<ProductFormData>({
    defaultValues: {
      name: "",
      categoryId: "",
      sku: "",
      description: "",
      price: "",
      cost: "",
      stock: "",
      minStock: "",
      unit: "pcs",
      barcode: "",
      product_image: null,
      product_gallery: null,
    },
    resolver: zodResolver(productSchema),
    mode: "all",
    criteriaMode: "all",
  });

  const productImage = watch("product_image");
  const canSubmit = isValid && productImage !== null;

  const onSubmit = async (data: ProductFormData) => {
    try {
      const formData = new FormData();
      formData.append("name", data.name);
      formData.append("categoryId", data.categoryId);
      formData.append("price", String(parseFloat(data.price) || 0));
      formData.append("stock", String(parseInt(data.stock) || 0));

      if (data.product_image) {
        formData.append("product_image", data.product_image);
      }

      if (data.product_gallery && Array.isArray(data.product_gallery)) {
        data.product_gallery.forEach((file) => {
          formData.append("product_gallery", file);
        });
      }

      // Submit form data
      await submitForm(formData);
    } catch (error) {
      console.error("Error submitting form:", error);
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Input
        label="Product Name"
        required
        error={errors.name?.message as string}
        {...register("name")}
      />

      <Input
        label="Price"
        type="number"
        step="0.01"
        required
        error={errors.price?.message as string}
        {...register("price")}
      />

      <FileInput
        accept="image/*"
        label="Product Image"
        required
        error={errors.product_image?.message as string}
        {...register("product_image")}
      />

      <button 
        type="submit" 
        disabled={isSubmitting || !canSubmit}
      >
        {isSubmitting ? "Submitting..." : "Submit"}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

A few things to note here:

  • mode: "all" validates on blur, change, and submit—giving users immediate feedback
  • criteriaMode: "all" shows all validation errors, not just the first one
  • The watch function lets us reactively check form state
  • TypeScript knows exactly what data contains thanks to Zod's type inference

Building Reusable Input Components

One of the best practices I've learned is to create reusable input components. Here's a clean implementation:

import React, { forwardRef } from "react";

type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
  label: string;
  error?: string;
  required?: boolean;
};

const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, required = false, ...rest }, ref) => {
    return (
      <div className="flex flex-col gap-1">
        <label className="block text-sm font-medium text-gray-900">
          {label}
          {required && <span className="text-red-500">*</span>}
        </label>
        <input
          ref={ref}
          {...rest}
          className={`block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 ${error ? "outline-red-500" : ""}`}
        />
        {error && <p className="text-red-500 text-xs mt-1">{error}</p>}
      </div>
    );
  }
);

Input.displayName = "Input";
export default Input;
Enter fullscreen mode Exit fullscreen mode

Using forwardRef is crucial here because React Hook Form needs to attach refs to your inputs. This pattern makes your forms consistent and maintainable.


Advanced Validation Patterns

Zod really shines when you need complex validation. Let me show you some patterns I use regularly:

Email Validation

const emailSchema = z.string().email({ message: "Invalid email address" });
Enter fullscreen mode Exit fullscreen mode

Simple, right? But Zod can do so much more.

Number Range Validation

const priceSchema = z.string().refine(
  (val) => {
    const num = parseFloat(val);
    return !isNaN(num) && num > 0;
  },
  { message: "Price must be greater than 0" }
);
Enter fullscreen mode Exit fullscreen mode

Custom Validation Logic

const stockSchema = z.string().refine(
  (val) => {
    const num = parseInt(val);
    return !isNaN(num) && num >= 0;
  },
  { message: "Stock must be a non-negative number" }
);
Enter fullscreen mode Exit fullscreen mode

Conditional Validation

This is where Zod gets really powerful. Need to validate a field only when another field has a certain value? No problem:

const conditionalSchema = z.object({
  hasDiscount: z.boolean(),
  discount: z.string().optional(),
}).refine(
  (data) => {
    if (data.hasDiscount) {
      return data.discount && parseFloat(data.discount) > 0;
    }
    return true;
  },
  { message: "Discount is required when hasDiscount is true", path: ["discount"] }
);
Enter fullscreen mode Exit fullscreen mode

The path option tells Zod which field to attach the error to, making your error messages appear exactly where they should.


Best Practices I've Learned the Hard Way

After building dozens of forms with this stack, here are the practices that have saved me countless hours:

  1. Use preprocess for file inputs: File inputs return FileList, but you'll want File objects. preprocess handles this transformation seamlessly.

  2. Always provide clear error messages: Generic messages like "Invalid" don't help users. Be specific: "Price must be greater than 0" is much better.

  3. Use mode: "all" for better UX: Validating on blur and change gives users immediate feedback without being annoying.

  4. Leverage TypeScript's type inference: That z.infer<typeof schema> is pure gold. Let TypeScript do the work for you.

  5. Create reusable validation schemas: If you validate emails in multiple forms, create a shared schema. DRY principles apply to validation too.

  6. Test your edge cases: Empty strings, null values, undefined—make sure your validation handles all the weird cases.


Real-World Tips

Here are some things I wish someone had told me when I started:

  • File uploads are tricky: Always use preprocess to convert FileList to File or File[] before validation
  • String vs Number: HTML inputs return strings, so validate them as strings and convert when submitting
  • Optional fields: Use .optional() or .nullable() explicitly—don't assume Zod knows what you mean
  • Error messages: Put them in the schema, not in your components. This keeps validation logic centralized

Conclusion

React Hook Form + Zod has become my go-to solution for form validation. It's type-safe, performant, and actually enjoyable to work with. The combination gives you:

  • ✅ Type safety at compile time
  • ✅ Runtime validation
  • ✅ Excellent developer experience
  • ✅ Great user experience
  • ✅ Minimal boilerplate

If you're still manually validating forms or using outdated libraries, give this combination a try. Your future self will thank you.


Want More?

I've put together a more detailed version of this guide on my website with additional examples, edge cases, and advanced patterns. You can check it out here:

React Hook Form with Zod Validation: Complete Guide

I also write about React, TypeScript, and web development regularly. If you found this helpful, consider visiting my blog for more tutorials and guides.


About the Author

I'm Maruf Rahman, a full-stack developer passionate about building great user experiences. You can find me on GitHub, LinkedIn,or visit my website to see more of my work.


If you found this guide helpful, please share it with others who might benefit. Happy coding! 🚀

Top comments (0)