React Hook Form: Type-Safe Schema Validation with Zod
Learn how React Hook Form and Zod combine to give you a single schema as the source of truth for type-safe form validation that never lets client and server drift apart.

Form validation is one of those things teams think they've solved until they've shipped a bug where the client accepted an input the server rejected. The mismatch happens because validation logic lives in two places at once: a Yup schema on the front end and a Zod schema on the back end, or worse, ad-hoc if-checks scattered across both. Every time a field changes, someone has to remember to update both sides.
React Hook Form paired with Zod closes that gap. You write one schema. The resolver wires it to your form. Your server action or API route imports the same schema. Client and server validation are, by definition, the same rules.
This is the approach the Laxaar team reaches for on every React project that has non-trivial forms. It isn't the only way to build forms in React. For anything beyond a contact form, though, we've found it pays back its small setup cost within the first week of development.
What you'll learn
- Why React Hook Form beats controlled inputs at scale
- Setting up the Zod resolver
- Building a multi-step form with one shared schema
- Controlled vs uncontrolled inputs and when each fits
- Server-side validation with the same schema
- Handling async validation and custom error messages
- Common mistakes and the real trade-offs
- Frequently Asked Questions
Why React Hook Form beats controlled inputs at scale
Controlled inputs (useState for every field, onChange handlers everywhere) are fine for a two-field login form. They stop being fine when you have 15 fields, conditional visibility, nested objects, and dynamic arrays.
The core problem with controlled inputs at scale is re-renders. Every keystroke calls setState, which triggers a re-render of the form component and every child that consumes the state. On complex forms, this creates perceptible lag. React Hook Form sidesteps this by using uncontrolled inputs under the hood. It registers each field with a ref rather than a state variable, reads values at submission time, and only triggers re-renders for fields that have actually changed validation state.
The practical result: a 20-field form with React Hook Form re-renders far less than the equivalent controlled form. On lower-end Android devices running a React Native port, this difference is measurable. On desktop, it's still the right architecture because it keeps your component tree predictable.
React Hook Form also ships a better default developer experience for validation feedback. formState.errors is a nested object that mirrors your field structure exactly. You don't have to build your own error state management.
Setting up the Zod resolver
React Hook Form doesn't know about Zod out of the box. The @hookform/resolvers package bridges them.
npm install react-hook-form zod @hookform/resolvers
The resolver pattern is straightforward: define a Zod schema, pass it to zodResolver, and hand that to useForm.
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const signupSchema = z.object({
email: z.string().email('Enter a valid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter'),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
})
type SignupFormValues = z.infer<typeof signupSchema>
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupFormValues>({
resolver: zodResolver(signupSchema),
})
const onSubmit = async (data: SignupFormValues) => {
// data is fully typed as SignupFormValues
await createAccount(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} type="email" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register('password')} type="password" />
{errors.password && <p>{errors.password.message}</p>}
<input {...register('confirmPassword')} type="password" />
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
<button type="submit" disabled={isSubmitting}>
Create account
</button>
</form>
)
}
z.infer<typeof signupSchema> gives you the TypeScript type for free. No separate interface to maintain. When you add a field to the schema, TypeScript will error on every place that touches form values but hasn't been updated. That's the type-safety payoff.
Building a multi-step form with one shared schema
Multi-step forms are where the single-schema approach earns its keep most visibly. The naive version has a separate schema per step, which means the same field can be validated differently depending on which step the user is on. That divergence is where bugs hide.
The better approach: define one complete schema, use Zod's .pick() to validate only the fields relevant to each step, and accumulate values across steps in a single object.
import { z } from 'zod'
// Single source of truth for the entire form
export const onboardingSchema = z.object({
// Step 1 — account
email: z.string().email(),
password: z.string().min(8),
// Step 2 — profile
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
role: z.enum(['developer', 'designer', 'manager', 'other']),
// Step 3 — preferences
newsletter: z.boolean().default(false),
timezone: z.string().min(1, 'Select a timezone'),
})
export type OnboardingValues = z.infer<typeof onboardingSchema>
// Step-scoped schemas derived from the single source
export const step1Schema = onboardingSchema.pick({ email: true, password: true })
export const step2Schema = onboardingSchema.pick({
firstName: true,
lastName: true,
role: true,
})
export const step3Schema = onboardingSchema.pick({
newsletter: true,
timezone: true,
})
Each step mounts its own useForm instance with the step-scoped schema as the resolver, but writes validated values into shared state when advancing.
const STEPS = [step1Schema, step2Schema, step3Schema]
export function OnboardingForm() {
const [step, setStep] = useState(0)
const [formData, setFormData] = useState<Partial<OnboardingValues>>({})
const currentSchema = STEPS[step]
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(currentSchema),
defaultValues: formData,
})
const advance = (stepValues: Partial<OnboardingValues>) => {
const merged = { ...formData, ...stepValues }
setFormData(merged)
if (step < STEPS.length - 1) {
setStep(s => s + 1)
} else {
// Final submission — parse the complete schema to confirm all values valid
const result = onboardingSchema.safeParse(merged)
if (result.success) {
submitOnboarding(result.data)
}
}
}
return (
<form onSubmit={handleSubmit(advance)}>
{step === 0 && <Step1Fields register={register} errors={errors} />}
{step === 1 && <Step2Fields register={register} errors={errors} />}
{step === 2 && <Step3Fields register={register} errors={errors} />}
<button type="submit">{step < 2 ? 'Next' : 'Submit'}</button>
</form>
)
}
The final onboardingSchema.safeParse(merged) call before submission is not paranoia. It's the guarantee that even if something unexpected happened to the accumulated state, the complete schema runs one last time before any data leaves the browser.
Controlled vs uncontrolled inputs and when each fits
React Hook Form defaults to uncontrolled inputs, which is the right default. But there are cases where you need controlled behavior.
| Scenario | Approach | Why |
|---|---|---|
| Native HTML inputs (text, email, number) | register() — uncontrolled | No re-renders on keystroke; best performance |
| Custom UI components (design system, date picker) | Controller or useController — controlled | Third-party components need a value prop |
| Real-time dependent validation (field A affects field B) | watch() + conditional logic | Need to observe another field's current value |
| Formatted inputs (currency, phone, card number) | Controller with input masking library | Need to intercept and reformat on change |
| Complex arrays of fields | useFieldArray | Built-in API for append, remove, swap operations |
The Controller component is the bridge for third-party inputs:
import { Controller } from 'react-hook-form'
import { DatePicker } from '@/components/ui/date-picker'
<Controller
name="startDate"
control={control}
render={({ field, fieldState }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
error={fieldState.error?.message}
/>
)}
/>
The honest trade-off here: Controller does add a controlled-input re-render for the wrapped field. For a date picker that's fine; the user interacts with it once. For a text field being typed into, prefer register unless you genuinely need controlled behavior.
Server-side validation with the same schema
This is the part most tutorials skip, and it's where the real value of sharing a schema emerges.
In a Next.js Server Action, you import the same Zod schema and parse the submitted form data before doing anything else with it. No separate validation library. No hand-rolled checks.
// app/actions/onboarding.ts
'use server'
import { onboardingSchema } from '@/lib/schemas/onboarding'
export async function submitOnboarding(rawData: unknown) {
const result = onboardingSchema.safeParse(rawData)
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
}
}
// result.data is fully typed and guaranteed valid
await db.users.create({ data: result.data })
return { success: true }
}
safeParse returns a discriminated union. The success: false branch gives you result.error: a structured Zod error you can flatten into field-level messages and return to the client. The success: true branch gives you result.data with full TypeScript types inferred from the schema.
Because the schema is shared, if a product requirement changes a validation rule (say, the minimum password length goes from 8 to 12), you change it once. Client and server both enforce the new rule immediately. No synchronization step, no second PR, no risk of the two drifting.
We use this pattern on every custom software development project at Laxaar where Next.js Server Actions are in play. It's one of those patterns that sounds like table stakes once you've used it and like over-engineering before you have.
Handling async validation and custom error messages
Zod covers synchronous validation well. For async cases (checking whether a username is already taken, validating a promo code against an API), React Hook Form's validate option handles it alongside the Zod resolver.
const { register, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema),
})
// Async validation runs after Zod passes synchronous checks
<input
{...register('username', {
validate: async (value) => {
const taken = await checkUsernameAvailability(value)
return taken ? 'This username is already taken' : true
},
})}
/>
Custom Zod error messages deserve more attention than they usually get. Zod accepts error map options that let you centralize all validation messages:
const schema = z.object({
age: z
.number({
required_error: 'Age is required',
invalid_type_error: 'Age must be a number',
})
.int('Age must be a whole number')
.min(18, 'You must be at least 18 years old')
.max(120, 'Enter a valid age'),
})
For i18n-ready forms, Zod's errorMap parameter at the schema level lets you swap in translated messages without changing the schema shape. That integration point is worth planning early if internationalization is in scope. Retrofitting it onto a large form codebase is painful.
For teams building forms as part of larger product work, the web development services conversation almost always surfaces validation architecture as a decision point. Getting it right from the start is significantly cheaper than fixing it after the form has grown to 30 fields.
Common mistakes and the real trade-offs
Mixing Zod and manual validation in the same form. Pick one. The moment you add if (!email.includes('@')) alongside a Zod schema, you've introduced a second validation layer to maintain. Zod can express everything a manual check can, with better error messages.
Forgetting mode in useForm. By default, React Hook Form only validates on submit. That's often the right UX for short forms but frustrating on long ones. mode: 'onBlur' validates when a field loses focus; mode: 'onChange' validates on every keystroke (with the performance cost of more re-renders). Choose deliberately.
const form = useForm({
resolver: zodResolver(schema),
mode: 'onBlur', // validate when user leaves a field
})
Using Zod's .transform() in a shared schema without thinking about it. Transforms run on parse, which means the TypeScript input type and output type are different. A schema that transforms a string "42" to a number 42 is useful on the server but confusing if the form field is bound to the pre-transform string value. Use z.coerce.number() for type coercion in form contexts, or split the schema into an input schema and an output schema for the server.
Not using formState: { errors } destructuring at the right level. React Hook Form's formState is a proxy. Destructuring errors at the top of your component subscribes to error state changes for the whole form. If you only need errors for one field, destructuring just that field's error is more efficient. Minor in practice, but worth knowing for performance-critical forms.
The real trade-off to acknowledge: React Hook Form plus Zod adds two dependencies and a resolver abstraction to your project. For a single-page form with three fields, that's overkill and we'd say so. useState is fine there. The pattern earns its complexity once you have multiple forms, shared validation rules, or server-side reuse of schema logic.
The Laxaar team recommends it as the default for any application where forms are a primary user interface: onboarding flows, settings pages, checkout experiences, admin dashboards. For everything else, reach for the simplest tool that works.
Explore how we structure front-end form architecture as part of our broader React development work and product engineering practice.
Frequently Asked Questions
Does React Hook Form work with React Server Components?
React Hook Form uses hooks, which means it only runs in Client Components. The 'use client' directive is required on any component that calls useForm. The pattern we use is to keep the form logic in a Client Component while the server action it calls runs in a Server Component context. The Zod schema sits in a shared module imported by both sides.
Can I use Yup instead of Zod with React Hook Form?
Yes. @hookform/resolvers supports Yup, Joi, Vest, and several others alongside Zod. The choice between Zod and Yup is mostly a matter of style: Zod has a more TypeScript-native API where types are inferred directly from the schema, while Yup requires you to define types separately. For new projects, we default to Zod because the inferred types eliminate a category of sync bugs between schema and TypeScript interfaces.
How do I handle file inputs with React Hook Form and Zod?
File inputs are a special case because <input type="file"> returns a FileList, not a string. Register the field normally with React Hook Form using register('avatar'). In the Zod schema, use z.instanceof(File) (or z.any() with a custom refinement if you need to check file type and size). On the server, file validation runs separately from the Zod parse since FormData file values aren't serialized as JSON.
What's the best way to show field-level errors from a server action back in the form?
Use useActionState (React 19) or react-dom's useFormState to capture the return value from the server action. When the action returns { errors: { fieldName: ['error message'] } }, call setError from React Hook Form to inject those errors back into formState.errors. This keeps the UX consistent: server errors appear inline next to their fields just like client-side Zod errors do.
Should validation mode be onSubmit or onBlur for better UX?
It depends on form length and user expectation. Short forms (2-4 fields) can validate on submit without frustrating users. Longer forms benefit from onBlur so users get feedback as they complete each field rather than a wall of errors on submission. A practical middle ground: use mode: 'onSubmit' with reValidateMode: 'onChange' so errors only appear on first submit but clear in real time once the user starts correcting them.
If you're working on a product with complex forms and still maintaining two sets of validation rules, reach out to the Laxaar team. We're happy to talk through how to consolidate them.
Working on something like this?
Get a fixed scope, timeline, and price within one business day — no obligation.


