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
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>;
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>
);
}
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
watchfunction lets us reactively check form state - TypeScript knows exactly what
datacontains 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;
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" });
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" }
);
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" }
);
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"] }
);
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:
Use
preprocessfor file inputs: File inputs returnFileList, but you'll wantFileobjects.preprocesshandles this transformation seamlessly.Always provide clear error messages: Generic messages like "Invalid" don't help users. Be specific: "Price must be greater than 0" is much better.
Use
mode: "all"for better UX: Validating on blur and change gives users immediate feedback without being annoying.Leverage TypeScript's type inference: That
z.infer<typeof schema>is pure gold. Let TypeScript do the work for you.Create reusable validation schemas: If you validate emails in multiple forms, create a shared schema. DRY principles apply to validation too.
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
preprocessto convertFileListtoFileorFile[]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)