Back to Blog
React12 min read

React Hook Form with Zod Validation: Complete Guide

Form validation is a crucial aspect of building modern React applications. React Hook Form combined with Zod provides a powerful, type-safe solution for handling forms. In this guide, we'll learn how to implement robust form validation with file uploads and complex validation rules.

Form validation is a crucial aspect of building modern React applications. React Hook Form combined with Zod provides a powerful, type-safe solution for handling forms. In this guide, we'll learn how to implement robust form validation with file uploads and complex validation rules.

Installation

First, let's install the required packages:

npm install react-hook-form @hookform/resolvers zod

Creating a Zod Schema

Let's create a product form schema with validation rules:

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>;

Setting Up React Hook Form

Now let's integrate React Hook Form with Zod validation:

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>
  );
}

Custom Input Components

Here's how to create reusable input components that work with React Hook Form:

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;

Advanced Validation Patterns

Zod allows for complex validation patterns. Here are some examples:

// Email validation
const emailSchema = z.string().email({ message: "Invalid email address" });

// 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" }
);

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

// Conditional validation
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"] }
);

Best Practices

  • Use Zod's preprocess to transform data before validation (useful for FileList conversions)
  • Always provide clear error messages for better UX
  • Use mode: "all" to validate on blur and change
  • Leverage TypeScript's type inference with z.infer
  • Create reusable validation schemas for consistency

Conclusion

React Hook Form with Zod provides a powerful, type-safe solution for form validation in React applications. The combination allows for complex validation rules, excellent TypeScript support, and great developer experience. This approach is especially useful in production applications where form validation is critical.