React Hook Form + Zod: Type-Safe Forms with Validation
React Hook Form + Zod gives you end-to-end type-safe forms without boilerplate. Here's how to wire them together and stop fighting your own validation logic.
Why React Hook Form and Zod Belong Together
Honestly, writing form validation by hand in React is one of those things that seems fine until your form has 12 fields and three conditional sections. You end up with a useState soup and an error object that nobody trusts. React Hook Form (RHF) v7 and Zod v3 fix this — not by being magic, but by doing exactly what they say on the box.
RHF handles the DOM subscriptions, the dirty/touched state, and the submission flow. Zod handles the shape of your data and the error messages. They're two separate concerns, and keeping them separate is the whole point. The @hookform/resolvers package is the thin bridge between them — it translates Zod's parse errors into the format RHF expects.
If you've already read about building forms with React Hook Form, you know the basics. This article goes further: adding Zod schemas, coercing types, and handling nested objects without losing your mind. We'll use React Hook Form v7.54.1 and Zod v3.23.8 throughout.
Installing and Wiring Up the Zod Resolver
You need three packages. That's it.
npm install react-hook-form zod @hookform/resolversOnce those are in, the setup is a single import change. Instead of passing validation rules inline to register(), you define a Zod schema once and hand it to useForm via the resolver. Everything downstream — TypeScript types, error messages, submit payload shape — flows from that one schema definition. No duplication.
Building a Real Form: Schema First
Here's the part where most tutorials show a login form with two fields. We'll do something slightly more real: a user profile form with an optional website URL, an age field that needs to be a number (not a string), and a password confirmation check. These are the cases where hand-rolled validation starts to crack.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const profileSchema = z
.object({
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username can\'t exceed 20 characters'),
email: z.string().email('That doesn\'t look like a valid email'),
age: z.coerce.number().int().min(13, 'Must be at least 13').max(120),
website: z.string().url('Enter a full URL including https://').optional().or(z.literal('')),
password: z.string().min(8, 'Password needs at least 8 characters'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords don\'t match',
path: ['confirmPassword'],
});
type ProfileFormValues = z.infer<typeof profileSchema>;
export function ProfileForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
username: '',
email: '',
age: undefined,
website: '',
password: '',
confirmPassword: '',
},
});
const onSubmit = async (data: ProfileFormValues) => {
// data is fully typed — no casting needed
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4 max-w-md">
<div>
<input
{...register('username')}
placeholder="Username"
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm"
/>
{errors.username && (
<p className="mt-1 text-xs text-red-400">{errors.username.message}</p>
)}
</div>
<div>
<input
{...register('age')}
type="number"
placeholder="Age"
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm"
/>
{errors.age && (
<p className="mt-1 text-xs text-red-400">{errors.age.message}</p>
)}
</div>
<div>
<input
{...register('website')}
placeholder="https://your-site.com (optional)"
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm"
/>
{errors.website && (
<p className="mt-1 text-xs text-red-400">{errors.website.message}</p>
)}
</div>
<div>
<input
{...register('password')}
type="password"
placeholder="Password"
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm"
/>
{errors.password && (
<p className="mt-1 text-xs text-red-400">{errors.password.message}</p>
)}
</div>
<div>
<input
{...register('confirmPassword')}
type="password"
placeholder="Confirm password"
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm"
/>
{errors.confirmPassword && (
<p className="mt-1 text-xs text-red-400">{errors.confirmPassword.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-500 disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : 'Save profile'}
</button>
</form>
);
}A few things worth calling out. The z.coerce.number() on age is critical — HTML inputs always give you strings, so without coercion Zod would reject every number the user types. The .or(z.literal('')) on the optional website field handles the empty string case that trips up a lot of people. And the .refine() at the schema level with an explicit path is how you attach cross-field errors to a specific field instead of the root.
Type Inference: The Part That Makes TypeScript Worth It
Notice the type ProfileFormValues = z.infer<typeof profileSchema> line. That single line means you never write the form type by hand. You define the schema, infer the type, and TypeScript catches every mismatch automatically. Change the schema, and any code that touches the form data updates its type errors immediately.
This matters most in the onSubmit handler. When data is ProfileFormValues, your editor knows data.age is a number, not string | number | undefined. You can call data.age.toFixed(0) without a cast. That's not a coincidence — it's what the resolver guarantees after a successful parse.
If you're working with TypeScript patterns across your React codebase, this schema-first approach fits naturally. Your Zod schemas become the single source of truth for data shapes, and you can reuse them for API response validation too — not just forms.
Handling Nested Objects and Arrays
Flat forms are easy. What about an address sub-object or a dynamic list of phone numbers? Zod handles both with z.object() nesting and z.array(). RHF handles the corresponding fields with dot-notation names like address.city and bracket notation like phones.0.number.
const checkoutSchema = z.object({
billing: z.object({
name: z.string().min(1, 'Name is required'),
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
postcode: z.string().regex(/^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$/i, 'Enter a valid UK postcode'),
}),
phones: z
.array(
z.object({
label: z.enum(['mobile', 'home', 'work']),
number: z.string().min(7, 'Phone number too short'),
})
)
.min(1, 'Add at least one phone number'),
});
type CheckoutValues = z.infer<typeof checkoutSchema>;
// In your component:
const { register, formState: { errors } } = useForm<CheckoutValues>({
resolver: zodResolver(checkoutSchema),
});
// Accessing nested errors:
// errors.billing?.city?.message
// errors.phones?.[0]?.number?.messageThe error path matches the field name path. That's intentional — RHF and Zod both use the same dot-notation convention, so the resolver can map errors without any manual path translation. When you have dynamic field arrays, pair this with RHF's useFieldArray hook, which gives you append, remove, and fields for rendering the list.
Validation Mode and When Errors Show Up
By default, RHF only validates on submit. That's usually fine, but sometimes you want inline feedback as the user types or leaves a field. The mode option controls this: 'onSubmit' (default), 'onBlur', 'onChange', or 'all'. There's also reValidateMode which controls re-validation after the first submit attempt.
For most forms, mode: 'onBlur' with reValidateMode: 'onChange' is the right call. The user gets errors when they leave a field, and those errors clear immediately when they fix the input. Showing errors character-by-character while someone's still typing their email is just annoying. Do you want your signup form screaming 'invalid email' before someone's finished typing the domain? Probably not.
You can also trigger validation programmatically with trigger('fieldName') or trigger() for the whole form. This is handy in multi-step forms where you want to validate the current step before moving to the next without submitting. Combine it with toast notifications for submission feedback and you've got a form UX that actually makes sense.
Server-Side Errors and the setError API
Zod catches everything you can validate on the client. But some errors only exist on the server — username already taken, payment method declined, rate limit hit. RHF's setError function lets you inject those back into the form's error state so they display next to the right field.
const onSubmit = async (data: ProfileFormValues) => {
try {
await api.updateProfile(data);
} catch (err) {
if (err.code === 'USERNAME_TAKEN') {
setError('username', {
type: 'server',
message: 'That username is already taken',
});
// Put focus back on the offending field
setFocus('username');
} else {
// Generic error on a non-field
setError('root.serverError', {
type: 'server',
message: 'Something went wrong. Try again in a moment.',
});
}
}
};
// Display root error somewhere above the submit button:
{errors.root?.serverError && (
<p className="rounded-md bg-red-950 px-3 py-2 text-sm text-red-300">
{errors.root.serverError.message}
</p>
)}The root.serverError path is a convention that RHF v7.29+ supports natively. It won't block submission (unlike field-level errors), so you need to check it manually if needed. But for most cases, just displaying it above the submit button is the right UX. Field-level server errors do block resubmission until the user edits that field, which is exactly what you want for a 'username taken' situation.
Performance: Why Re-Renders Aren't a Problem Here
One concern people raise about form libraries is re-renders. A naive controlled-input approach re-renders the whole form on every keystroke. RHF avoids this by using uncontrolled inputs and only subscribing to the state you actually read. If you only destructure errors and isSubmitting from formState, the component only re-renders when those change.
Zod's safeParse (which the resolver uses internally) is fast for typical form schemas. You're not going to notice it. Where you might notice performance is in very large field arrays — 500+ rows — but that's a different problem and usually means you shouldn't be rendering all those rows at once anyway. For standard SaaS forms, the RHF + Zod stack adds essentially no measurable overhead. Check out React performance patterns if you're dealing with larger-scale rendering issues elsewhere in your app.
One genuine gotcha: if you wrap Controller around a third-party component (like a date picker or a select library), make sure you're not also passing {...register()} to it. Pick one or the other. Controller gives you field.onChange and field.value for components that need controlled behavior, while register is for standard HTML inputs that can be uncontrolled. Mixing them causes duplicate event handlers and confusing behavior.
FAQ
They do different things. Zod parses and validates data shapes — it doesn't know anything about React or DOM events. React Hook Form manages form state, field registration, submission, and dirty/touched tracking. You need both. The @hookform/resolvers package connects them with about 2 lines of code.
HTML number inputs return strings, not numbers. z.number() on its own will fail because '42' is not a number in Zod's view. z.coerce.number() runs Number() on the input first, so it correctly handles what browsers give you. Use it on any numeric field that comes from an HTML input.
Use z.string().optional() combined with a transform: z.string().transform(val => val === '' ? undefined : val).optional(). Alternatively, use z.preprocess() at the field level. The .or(z.literal('')) pattern is useful when you want to allow empty strings through but treat them as 'no value' in your submit handler.
Yes, and you should. Define your schema in a shared location (e.g., lib/schemas.ts), import it in both the form component and your API client. The types inferred with z.infer<> will be identical, so your form submission payload type matches your API expectation type automatically.
mode controls when validation first runs — before any submission attempt. reValidateMode controls re-validation after the first submit (or after an error is shown). The common pattern is mode: 'onBlur' so errors show when the user leaves a field, and reValidateMode: 'onChange' so errors clear immediately once the user starts fixing them.
React Hook Form tracks this for you. Destructure isSubmitting from formState: const { formState: { isSubmitting } } = useForm(). It's true from when handleSubmit calls your async onSubmit until that promise resolves or rejects. No useState needed. Just disable the button and swap the label based on isSubmitting.