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
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>;
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>
);
}
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"
});
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"
}
})}
/>
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>
watch Method
Watch specific fields for conditional logic:
const email = watch("email");
const { email, password } = watch(["email", "password"]);
Advanced Validation Patterns
Email Validation
const emailSchema = z
.string()
.min(1, { message: "Email is required" })
.email({ message: "Please enter a valid email address" })
.toLowerCase();
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" });
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" }
);
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"]
}
);
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}
/>
)}
/>
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>
);
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
- Always provide defaultValues: Prevents undefined errors and improves UX
- Leverage TypeScript: Use Zod schemas for full type safety
- Minimize watch() usage: Only watch fields when necessary
- Use Controller for UI libraries: Essential for Material UI, Chakra UI, etc.
-
Validate on blur and change: Set
mode: "all"for immediate feedback - Create reusable schemas: Build consistent validation across your app
- Handle errors gracefully: Display clear, actionable error messages
- Reset after submission: Always reset forms after successful submission
- 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>
);
}
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"
/>
)}
/>
Resources and Further Reading
- 📚 Full React Hook Form Guide - Complete tutorial with advanced examples, troubleshooting, and best practices
- React Hook Form Documentation - Official documentation with examples
- Zod Documentation - Schema validation library
- @hookform/resolvers - Validation resolvers
- React Hook Form GitHub - Source code and issues
- TypeScript with React Best Practices - Learn TypeScript patterns for React
- TanStack Table Implementation Guide - Build advanced data tables
- Redux Toolkit RTK Query Guide - State management patterns
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)