DEV Community

Cover image for React Hook Form with Zod: Complete Guide for 2026
Md. Maruf Rahman
Md. Maruf Rahman

Posted on • Originally published at marufrahman.live

React Hook Form with Zod: Complete Guide for 2026

Form validation used to be my least favorite part of frontend development. Between managing state with useState, handling errors, and ensuring type safety, it felt like I was writing more boilerplate than actual logic. Then I discovered React Hook Form combined with Zod, and everything changed.

What makes React Hook Form so powerful isn't just that it reduces code—it's that it makes forms actually enjoyable to build. React Hook Form uses the useForm hook to handle all the performance optimizations (minimal re-renders, uncontrolled components), while Zod gives you runtime validation that matches your TypeScript types perfectly.

📖 Want the complete guide with more examples and advanced patterns? Check out the full article on my blog for an in-depth tutorial with additional code examples, troubleshooting tips, and real-world use cases.

Why React Hook Form?

  • Minimal re-renders: Uses uncontrolled components with refs
  • Small bundle size: ~9KB (gzipped)
  • Excellent TypeScript support: Full type inference with Zod
  • Built-in validation: Works seamlessly with validation libraries
  • Better performance: Faster than alternatives like Formik

Installation

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

Building Your First Schema

Let's start with a real-world example: a product form. This schema covers most validation scenarios you'll encounter:

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" }),
  price: z.string().trim().min(1, { message: "Required" }),
  stock: z.string().trim().min(1, { message: "Required" }),
  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

Complete Form Example

Here's a production-ready React Hook Form component:

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

function AddProduct() {
  const [submitError, setSubmitError] = useState<string | null>(null);
  const [submitSuccess, setSubmitSuccess] = useState(false);

  const {
    register,
    handleSubmit,
    watch,
    reset,
    formState: { errors, isSubmitting, isValid },
  } = useForm<ProductFormData>({
    defaultValues: {
      name: "",
      categoryId: "",
      price: "",
      stock: "",
      product_image: null,
      product_gallery: null,
    },
    resolver: zodResolver(productSchema),
    mode: "all", // Validate on blur, change, and submit
    criteriaMode: "all", // Show all validation errors
  });

  const productImage = watch("product_image");

  const onSubmit = async (data: ProductFormData) => {
    try {
      setSubmitError(null);

      const formData = new FormData();
      formData.append("name", data.name.trim());
      formData.append("price", String(parseFloat(data.price) || 0));

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

      const response = await fetch("/api/products", {
        method: "POST",
        body: formData,
      });

      if (!response.ok) {
        throw new Error("Failed to create product");
      }

      setSubmitSuccess(true);
      reset();
    } catch (error) {
      setSubmitError(error instanceof Error ? error.message : "An error occurred");
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register("name")}
        placeholder="Product Name"
      />
      {errors.name && <p>{errors.name.message}</p>}

      <input
        type="file"
        accept="image/*"
        {...register("product_image")}
      />
      {errors.product_image && <p>{errors.product_image.message}</p>}

      {productImage && (
        <img
          src={URL.createObjectURL(productImage)}
          alt="Preview"
          className="w-32 h-32"
        />
      )}

      <button
        type="submit"
        disabled={isSubmitting || !isValid}
      >
        {isSubmitting ? "Creating..." : "Create Product"}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key React Hook Form Methods

useForm Hook

The useForm hook is the core of React Hook Form. It returns methods and properties to manage your form state:

const {
  register,        // Register input fields
  handleSubmit,    // Handle form submission
  watch,          // Watch field values
  reset,          // Reset form
  trigger,         // Manually trigger validation
  setValue,        // Set field values programmatically
  formState: { errors, isSubmitting, isValid }
} = useForm({
  resolver: zodResolver(schema),
  defaultValues: { /* initial values */ },
  mode: "all"
});
Enter fullscreen mode Exit fullscreen mode

register Method

Register input fields with React Hook Form:

<input 
  {...register("email", { 
    required: "Email is required",
    pattern: {
      value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}$/i,
      message: "Invalid email address"
    }
  })} 
/>
Enter fullscreen mode Exit fullscreen mode

handleSubmit

Wrap your submit handler to ensure validation runs first:

const onSubmit = (data: FormData) => {
  console.log("Form data:", data);
};

<form onSubmit={handleSubmit(onSubmit)}>
  {/* form fields */}
</form>
Enter fullscreen mode Exit fullscreen mode

watch Method

Watch specific fields for conditional logic:

const email = watch("email");
const { email, password } = watch(["email", "password"]);
Enter fullscreen mode Exit fullscreen mode

Advanced Validation Patterns

Email Validation

const emailSchema = z
  .string()
  .min(1, { message: "Email is required" })
  .email({ message: "Please enter a valid email address" })
  .toLowerCase();
Enter fullscreen mode Exit fullscreen mode

Password Strength Validation

const passwordSchema = z
  .string()
  .min(8, { message: "Password must be at least 8 characters" })
  .regex(/[A-Z]/, { message: "Password must contain at least one uppercase letter" })
  .regex(/[a-z]/, { message: "Password must contain at least one lowercase letter" })
  .regex(/[0-9]/, { message: "Password must contain at least one number" });
Enter fullscreen mode Exit fullscreen mode

File Upload Validation

const imageFileSchema = z
  .instanceof(File)
  .refine(
    (file) => file.size <= 5 * 1024 * 1024, // 5MB max
    { message: "Image size must be less than 5MB" }
  )
  .refine(
    (file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type),
    { message: "Only JPEG, PNG, and WebP images are allowed" }
  );
Enter fullscreen mode Exit fullscreen mode

Conditional Validation

const productWithDiscountSchema = z
  .object({
    hasDiscount: z.boolean(),
    discount: z.string().optional(),
    price: z.string(),
  })
  .refine(
    (data) => {
      if (data.hasDiscount) {
        if (!data.discount) return false;
        const discountValue = parseFloat(data.discount);
        const priceValue = parseFloat(data.price);
        return discountValue > 0 && discountValue < priceValue;
      }
      return true;
    },
    { 
      message: "Discount is required and must be less than price", 
      path: ["discount"] 
    }
  );
Enter fullscreen mode Exit fullscreen mode

Controller Component

Use Controller when integrating with UI libraries like Material UI or Chakra UI:

import { Controller } from "react-hook-form";
import { TextField } from "@mui/material";

<Controller
  name="email"
  control={control}
  render={({ field, fieldState }) => (
    <TextField
      {...field}
      label="Email"
      error={!!fieldState.error}
      helperText={fieldState.error?.message}
    />
  )}
/>
Enter fullscreen mode Exit fullscreen mode

useFieldArray for Dynamic Forms

Manage dynamic arrays of form fields:

import { useFieldArray } from "react-hook-form";

const { fields, append, remove } = useFieldArray({
  control,
  name: "users"
});

return (
  <form>
    {fields.map((field, index) => (
      <div key={field.id}>
        <input {...register(`users.${index}.name`)} />
        <button type="button" onClick={() => remove(index)}>
          Remove
        </button>
      </div>
    ))}
    <button type="button" onClick={() => append({ name: "", email: "" })}>
      Add User
    </button>
  </form>
);
Enter fullscreen mode Exit fullscreen mode

React Hook Form vs useState

While useState works for simple forms, React Hook Form provides:

  • Better performance: Fewer re-renders with uncontrolled components
  • Less boilerplate: No need to manage state for each field
  • Built-in validation: Integrates seamlessly with validation libraries
  • Better error handling: Automatic error tracking and display
  • Type safety: Full TypeScript support with Zod

React Hook Form vs Formik

Feature React Hook Form Formik
Component Type Uncontrolled (refs) Controlled
Re-renders Minimal More frequent
Bundle Size ~9KB ~15KB
TypeScript Excellent Good
Performance Better for complex forms Can be slower

Best Practices

  1. Always provide defaultValues: Prevents undefined errors and improves UX
  2. Leverage TypeScript: Use Zod schemas for full type safety
  3. Minimize watch() usage: Only watch fields when necessary
  4. Use Controller for UI libraries: Essential for Material UI, Chakra UI, etc.
  5. Validate on blur and change: Set mode: "all" for immediate feedback
  6. Create reusable schemas: Build consistent validation across your app
  7. Handle errors gracefully: Display clear, actionable error messages
  8. Reset after submission: Always reset forms after successful submission
  9. Validate on both client and server: Client-side improves UX, server-side is essential for security

Next.js Integration

React Hook Form works seamlessly with Next.js:

"use client"; // Required for App Router

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

export default function ContactForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema)
  });

  const onSubmit = async (data) => {
    const response = await fetch("/api/contact", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data)
    });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* form fields */}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

React Native Support

React Hook Form works with React Native, but use Controller for all inputs:

import { Controller } from "react-hook-form";
import { TextInput } from "react-native";

<Controller
  name="email"
  control={control}
  render={({ field: { onChange, value } }) => (
    <TextInput
      value={value}
      onChangeText={onChange}
      placeholder="Email"
      keyboardType="email-address"
    />
  )}
/>
Enter fullscreen mode Exit fullscreen mode

Resources and Further Reading

Conclusion

React Hook Form with Zod has become my go-to solution for form handling in React. The combination gives you type safety, excellent performance, and a developer experience that makes building forms enjoyable.

The key benefits: reduced bundle size, minimal re-renders, and the peace of mind that comes from having your validation logic match your TypeScript types perfectly.

If you're just getting started, focus on mastering the basics: creating Zod schemas, connecting them with zodResolver, and handling errors. Once comfortable, explore advanced features like file uploads, conditional validation, and custom validation rules.

Remember: form validation is not just about preventing invalid data—it's about creating a smooth user experience. React Hook Form and Zod together make it easier than ever to build forms that are both robust and user-friendly.


What's your experience with React Hook Form? Share your tips and tricks in the comments below! 🚀


💡 Looking for more details? This is a condensed version of my comprehensive guide. Read the full article on my blog for additional examples, advanced patterns, file upload handling, multi-step forms, and more in-depth explanations.

If you found this guide helpful, consider checking out my other articles on React development and web development best practices.

Top comments (0)