Form Validation with react-hook-form and Zod
Build type-safe forms in React using react-hook-form with Zod schema validation, including nested fields, async rules, dynamic fields, and UI library integration.
Overview
react-hook-form keeps re-renders low by registering uncontrolled inputs instead of tracking every
keystroke. zod lets you write a validation schema and pull a TypeScript type out of it. Add
@hookform/resolvers/zod and the form validates against the schema while the values come out
already typed. The result: a form that’s type-safe, fast, and keeps validation rules in one place.
I picked this combo after fighting Formik on a checkout form with ~30 fields. Every keystroke
re-rendered the whole tree, and validation logic was scattered across useEffect hooks. Switching
to react-hook-form + Zod cut the re-renders to near zero and moved every rule into a single schema
file. If you’ve worked with discriminated unions in TypeScript,
Zod’s schema API will feel familiar — it mirrors the type system you already use.
When to Use
This combo shines when validation gets messy: conditional rules, cross-field checks, async server-side checks, nested objects, or field arrays. It’s also the right call for large forms where every keystroke shouldn’t trigger a re-render, and for integrating with UI libraries such as shadcn/ui, Material UI, or Chakra. For inputs that fire rapidly (search-as-you-type), pair it with a debounce or throttle to avoid hammering your async validators.
When NOT to Use
For a form with one or two fields, a bit of useState and a manual check is enough. If you need
rich visual feedback on every keystroke, a fully controlled component might be simpler. And
obviously, it only works in React.
Solution
Setup
npm install react-hook-form @hookform/resolvers zod
Basic form with Zod validation
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
age: z.number().min(18, "Must be at least 18").max(120, "Invalid age"),
});
type FormData = z.infer<typeof schema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormData) => {
await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Name</label>
<input id="name" type="text" {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<label htmlFor="age">Age</label>
<input id="age" type="number" {...register("age", { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Sign up"}
</button>
</form>
);
}
Nested object validation
const schema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
address: z.object({
street: z.string().min(1, "Street is required"),
city: z.string().min(1, "City is required"),
zipCode: z.string().regex(/^\d{5}$/, "Must be 5 digits"),
}),
preferences: z.object({
newsletter: z.boolean().default(false),
notifications: z.enum(["all", "important", "none"]),
}),
});
type FormData = z.infer<typeof schema>;
function ProfileForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({ resolver: zodResolver(schema) });
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register("firstName")} placeholder="First name" />
<input {...register("lastName")} placeholder="Last name" />
<input {...register("address.street")} placeholder="Street" />
{errors.address?.street && <span>{errors.address.street.message}</span>}
<input {...register("address.city")} placeholder="City" />
{errors.address?.city && <span>{errors.address.city.message}</span>}
<input {...register("address.zipCode")} placeholder="ZIP" />
{errors.address?.zipCode && <span>{errors.address.zipCode.message}</span>}
<select {...register("preferences.notifications")}>
<option value="all">All</option>
<option value="important">Important only</option>
<option value="none">None</option>
</select>
<label>
<input type="checkbox" {...register("preferences.newsletter")} />
Subscribe to newsletter
</label>
<button type="submit">Save</button>
</form>
);
}
Cross-field validation
const schema = z.object({
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type FormData = z.infer<typeof schema>;
function PasswordForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({ resolver: zodResolver(schema) });
return (
<form onSubmit={handleSubmit(console.log)}>
<input type="password" {...register("password")} placeholder="Password" />
{errors.password && <span>{errors.password.message}</span>}
<input type="password" {...register("confirmPassword")} placeholder="Confirm" />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<button type="submit">Set password</button>
</form>
);
}
Async validation
const schema = z.object({
username: z.string().min(3).refine(
async (username) => {
const res = await fetch(`/api/check-username?u=${username}`);
const { available } = await res.json();
return available;
},
"Username already taken"
),
email: z.string().email(),
});
function UsernameForm() {
const {
register,
handleSubmit,
formState: { errors, isValidating },
} = useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) });
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register("username")} placeholder="Username" />
{isValidating && <span>Checking...</span>}
{errors.username && <span>{errors.username.message}</span>}
<input {...register("email")} placeholder="Email" />
{errors.email && <span>{errors.email.message}</span>}
<button type="submit">Register</button>
</form>
);
}
Field arrays (dynamic fields)
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
items: z.array(
z.object({
name: z.string().min(1, "Item name required"),
quantity: z.number().min(1, "At least 1"),
price: z.number().min(0, "Must be positive"),
})
).min(1, "At least one item required"),
});
type FormData = z.infer<typeof schema>;
function InvoiceForm() {
const { register, control, handleSubmit, formState: { errors } } =
useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { items: [{ name: "", quantity: 1, price: 0 }] },
});
const { fields, append, remove } = useFieldArray({ control, name: "items" });
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} placeholder="Item name" />
<input type="number" {...register(`items.${index}.quantity`, { valueAsNumber: true })} />
<input type="number" {...register(`items.${index}.price`, { valueAsNumber: true })} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
{errors.items?.message && <span>{errors.items.message}</span>}
<button type="button" onClick={() => append({ name: "", quantity: 1, price: 0 })}>
Add item
</button>
<button type="submit">Submit</button>
</form>
);
}
Conditional validation
const schema = z.discriminatedUnion("type", [
z.object({
type: z.literal("individual"),
firstName: z.string().min(1),
lastName: z.string().min(1),
ssn: z.string().regex(/^\d{3}-\d{2}-\d{4}$/),
}),
z.object({
type: z.literal("business"),
companyName: z.string().min(1),
taxId: z.string().regex(/^\d{2}-\d{7}$/),
}),
]);
type FormData = z.infer<typeof schema>;
function RegistrationForm() {
const { register, handleSubmit, watch, formState: { errors } } =
useForm<FormData>({ resolver: zodResolver(schema) });
const type = watch("type");
return (
<form onSubmit={handleSubmit(console.log)}>
<select {...register("type")}>
<option value="individual">Individual</option>
<option value="business">Business</option>
</select>
{type === "individual" && (
<>
<input {...register("firstName")} placeholder="First name" />
<input {...register("lastName")} placeholder="Last name" />
<input {...register("ssn")} placeholder="SSN" />
</>
)}
{type === "business" && (
<>
<input {...register("companyName")} placeholder="Company name" />
<input {...register("taxId")} placeholder="Tax ID" />
</>
)}
<button type="submit">Register</button>
</form>
);
}
Integration with shadcn/ui
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
const schema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "Min 8 characters"),
});
type FormData = z.infer<typeof schema>;
function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
});
return (
<form onSubmit={handleSubmit(console.log)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" {...register("email")} />
{errors.email && <p className="text-sm text-red-500">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" {...register("password")} />
{errors.password && <p className="text-sm text-red-500">{errors.password.message}</p>}
</div>
<Button type="submit" className="w-full">Login</Button>
</form>
);
}
Variants
Using FormProvider for nested components
import { FormProvider, useForm, useFormContext } from "react-hook-form";
function AddressFields() {
const { register, formState: { errors } } = useFormContext();
return (
<>
<input {...register("address.street")} placeholder="Street" />
{errors.address?.street && <span>{errors.address.street.message}</span>}
</>
);
}
function CheckoutForm() {
const methods = useForm<FormData>({ resolver: zodResolver(schema) });
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(console.log)}>
<AddressFields />
<button type="submit">Submit</button>
</form>
</FormProvider>
);
}
Resetting form after submission
function EditProfileForm({ defaultValues }: { defaultValues: FormData }) {
const { register, handleSubmit, reset } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues,
});
const onSubmit = async (data: FormData) => {
await saveProfile(data);
reset(data); // Reset to the new saved values
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* fields */}
<button type="submit">Save</button>
<button type="button" onClick={() => reset()}>Reset</button>
</form>
);
}
Explanation
The key insight here: react-hook-form is uncontrolled by default. register() spreads props
onto a native input, and the form reads values on submit — no useState per field, no re-render
per keystroke. Zod handles validation as the single source of truth. You write the schema once,
derive the TypeScript type with z.infer, and the resolver wires the two together.
The flow above is the whole mental model: schema → resolver → form → typed output. There’s no
separate type definition and no separate validation config — Zod handles both. If you’ve used
utility types and generics before, the z.infer
pattern is the same idea: derive types from a single declaration rather than maintaining two
parallel definitions.
One thing to watch out for: useFieldArray and watch do trigger re-renders by design. This
is expected — they’re opt-in, so you only pay for them when you need them. The default path
(register + submit) stays uncontrolled.
Best Practices
Derive the form type from the Zod schema with z.infer<typeof schema> instead of duplicating it.
For number inputs, add valueAsNumber: true so the parsed value is actually a number, not a string.
Set defaultValues for every field; react-hook-form works better with initial values, especially
for checkboxes and selects.
If you want feedback before the user hits submit, switch to mode: "onBlur" or mode: "onChange".
For repeated or dynamic fields, useFieldArray is much safer than managing array indices manually.
When
the form is split across child components, use FormProvider to avoid prop drilling. Keep
validation rules inside the Zod schema rather than scattering them across the component. For
performance guidance, see When to Use useMemo and
useCallback.
Common Mistakes
- Forgetting
valueAsNumber. Number inputs return strings by default, soz.number()rejects them unless you addvalueAsNumber: true. - Skipping
defaultValues. Without them, checkboxes and selects can end upundefined. - Validating only on submit. Nobody likes filling out a long form, hitting submit, and
discovering half the fields were wrong.
mode: "onBlur"fixes this — validation runs when the user leaves each field. - Not typing the resolver. Always pass the generic so
useForm<FormData>({ resolver: zodResolver(schema) })gives you typed values. - Using controlled components unnecessarily.
registerreturns props for uncontrolled inputs, so don’t layer your ownvalueandonChangeon top.
See Also
- react-hook-form docs — API reference for
useForm,useFieldArray,Controller, andFormProvider. Worth bookmarking — the examples cover edge cases that the README doesn’t. - Zod docs — schema declaration,
z.infer,refine,discriminatedUnion, and error customization. Start with the README, then dig into the API reference. - @hookform/resolvers — adapter package that connects Zod, Yup, Valibot, and others to react-hook-form. Check the supported versions matrix.
- shadcn/ui Form — wraps react-hook-form with Radix primitives and Tailwind styling. Good reference for integrating the two in a real app.
Frequently Asked Questions
How do I validate on change instead of on submit?
useForm<FormData>({
resolver: zodResolver(schema),
mode: "onChange",
});
Can I use react-hook-form without Zod?
Yes. For simple cases you can pass rules directly to register, like register("name", { required: true, minLength: 2 }). Zod becomes worth it once the schema grows or you want to reuse it in more than one form.
How do I set a field value programmatically?
const { setValue } = useForm<FormData>();
setValue("email", "test@example.com", { shouldValidate: true });
How do I watch a field value?
const email = watch("email");
// or
const { watch } = useForm<FormData>();
const subscription = watch((value) => console.log(value));
Can I use react-hook-form with React Native?
Yes, but you'll need Controller rather than register — React Native's TextInput doesn't
expose a plain ref, so the usual spread approach doesn't work:
import { Controller } from "react-hook-form";
<Controller
control={control}
name="email"
render={({ field: { onChange, value } }) => (
<TextInput onChangeText={onChange} value={value} />
)}
/>
Why are my number fields showing up as strings?
Number inputs return strings in the DOM. If your Zod schema uses z.number(), validation fails
unless you add valueAsNumber: true to register:
<input type="number" {...register("age", { valueAsNumber: true })} />
This is the single most common gotcha I see in code reviews.
What's the difference between watch and useWatch?
watch subscribes at the form level and re-renders the whole component on every change.
useWatch scopes the subscription to a specific field or array, so only the component that
needs the value re-renders. For large forms, useWatch is almost always the better choice.
Related Resources
When to Use useMemo and useCallback
How and when to use React's useMemo and useCallback hooks for performance optimization, and when they add unnecessary overhead.
RecipeVirtualize Long Lists with react-window
How to render large lists efficiently in React using react-window for DOM virtualization, including fixed and variable height rows and grid layouts.
RecipeExhaustive Type Checking with Discriminated Unions
How to use TypeScript discriminated unions for exhaustive type checking, ensuring all cases are handled at compile time with never type assertions.
RecipeBuild Reusable Utility Types with Generics
How to create reusable TypeScript utility types using conditional types, mapped types, template literals, and generic constraints for type-safe APIs.
RecipeDebounce and Throttle Functions in JavaScript
Control function execution rate with debounce and throttle. Covers leading and trailing edge, cancelable timers, and real-world use cases.
RecipeServer-Side Rendering
Improve performance and SEO with server-side rendering using Next.js, Nuxt, Astro, and other frameworks with hydration strategies.