Tools: React Hook Form with Zod: Complete Guide for 2026

Tools: React Hook Form with Zod: Complete Guide for 2026

Source: Dev.to

Why React Hook Form? ## Installation ## Building Your First Schema ## Complete Form Example ## Key React Hook Form Methods ## useForm Hook ## register Method ## handleSubmit ## watch Method ## Advanced Validation Patterns ## Email Validation ## Password Strength Validation ## File Upload Validation ## Conditional Validation ## Controller Component ## useFieldArray for Dynamic Forms ## React Hook Form vs useState ## React Hook Form vs Formik ## Best Practices ## Next.js Integration ## React Native Support ## Resources and Further Reading ## Conclusion 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. Let's start with a real-world example: a product form. This schema covers most validation scenarios you'll encounter: Here's a production-ready React Hook Form component: The useForm hook is the core of React Hook Form. It returns methods and properties to manage your form state: Register input fields with React Hook Form: Wrap your submit handler to ensure validation runs first: Watch specific fields for conditional logic: Use Controller when integrating with UI libraries like Material UI or Chakra UI: Manage dynamic arrays of form fields: While useState works for simple forms, React Hook Form provides: React Hook Form works seamlessly with Next.js: React Hook Form works with React Native, but use Controller for all inputs: 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. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK: npm install react-hook-form @hookform/resolvers zod Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: npm install react-hook-form @hookform/resolvers zod COMMAND_BLOCK: npm install react-hook-form @hookform/resolvers zod COMMAND_BLOCK: 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 COMMAND_BLOCK: 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>; COMMAND_BLOCK: 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>; COMMAND_BLOCK: 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 COMMAND_BLOCK: 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> ); } COMMAND_BLOCK: 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> ); } CODE_BLOCK: 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 CODE_BLOCK: 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" }); CODE_BLOCK: 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" }); CODE_BLOCK: <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 CODE_BLOCK: <input {...register("email", { required: "Email is required", pattern: { value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}$/i, message: "Invalid email address" } })} /> CODE_BLOCK: <input {...register("email", { required: "Email is required", pattern: { value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}$/i, message: "Invalid email address" } })} /> COMMAND_BLOCK: const onSubmit = (data: FormData) => { console.log("Form data:", data); }; <form onSubmit={handleSubmit(onSubmit)}> {/* form fields */} </form> Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: const onSubmit = (data: FormData) => { console.log("Form data:", data); }; <form onSubmit={handleSubmit(onSubmit)}> {/* form fields */} </form> COMMAND_BLOCK: const onSubmit = (data: FormData) => { console.log("Form data:", data); }; <form onSubmit={handleSubmit(onSubmit)}> {/* form fields */} </form> CODE_BLOCK: const email = watch("email"); const { email, password } = watch(["email", "password"]); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: const email = watch("email"); const { email, password } = watch(["email", "password"]); CODE_BLOCK: const email = watch("email"); const { email, password } = watch(["email", "password"]); CODE_BLOCK: 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 CODE_BLOCK: const emailSchema = z .string() .min(1, { message: "Email is required" }) .email({ message: "Please enter a valid email address" }) .toLowerCase(); CODE_BLOCK: const emailSchema = z .string() .min(1, { message: "Email is required" }) .email({ message: "Please enter a valid email address" }) .toLowerCase(); CODE_BLOCK: 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 CODE_BLOCK: 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" }); CODE_BLOCK: 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" }); COMMAND_BLOCK: 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 COMMAND_BLOCK: 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" } ); COMMAND_BLOCK: 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" } ); COMMAND_BLOCK: 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 COMMAND_BLOCK: 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"] } ); COMMAND_BLOCK: 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"] } ); COMMAND_BLOCK: 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 COMMAND_BLOCK: 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} /> )} /> COMMAND_BLOCK: 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} /> )} /> COMMAND_BLOCK: 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 COMMAND_BLOCK: 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> ); COMMAND_BLOCK: 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> ); COMMAND_BLOCK: "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 COMMAND_BLOCK: "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> ); } COMMAND_BLOCK: "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> ); } COMMAND_BLOCK: 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 COMMAND_BLOCK: 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" /> )} /> COMMAND_BLOCK: 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" /> )} /> - 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 - 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 - 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 - 📚 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