Form Validation in React: Zod, React Hook Form, and Custom Validation Patterns

Form validation is one of the few pieces of UI logic that runs on the client, the server, and sometimes in API middleware. Keeping validation synchronized across all three layers used to be a manual, error-prone process. With TypeScript-first schema libraries like Zod, you can define validation once and reuse it everywhere. Combined with React Hook Form for performant form state management, this pattern produces forms that are fast, type-safe, and accessible.
Defining Validation Schemas with Zod
Zod schemas define the shape and constraints of your data in a single source of truth. The same schema validates form input in the browser and API payloads on the server:
import { z } from 'zod'
export const contactFormSchema = z.object({
name: z
.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be under 100 characters'),
email: z
.string()
.email('Please enter a valid email address'),
subject: z
.string()
.min(5, 'Subject must be at least 5 characters'),
message: z
.string()
.min(10, 'Message must be at least 10 characters')
.max(2000, 'Message must be under 2000 characters'),
priority: z.enum(['low', 'normal', 'high'], {
errorMap: () => ({ message: 'Please select a priority level' }),
}),
})
export type ContactFormData = z.infer<typeof contactFormSchema>
The z.infer utility extracts the TypeScript type directly from your schema, guaranteeing type alignment between validation logic and application code. If you change a constraint in the schema, TypeScript surfaces every usage that needs updating.
Integrating Zod with React Hook Form
React Hook Form minimizes re-renders by using uncontrolled component registration. The @hookform/resolvers package bridges Zod schemas with React Hook Form's validation pipeline:
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { contactFormSchema, type ContactFormData } from '@/lib/schemas'
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
} = useForm<ContactFormData>({
resolver: zodResolver(contactFormSchema),
mode: 'onTouched', // validate on blur and change
})
const onSubmit = async (data: ContactFormData) => {
const response = await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(data),
})
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="name">Name</label>
<input id="name" {...register('name')} aria-invalid={!!errors.name} />
{errors.name && (
<span role="alert" className="error">{errors.name.message}</span>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register('email')} />
{errors.email && (
<span role="alert" className="error">{errors.email.message}</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send'}
</button>
</form>
)
}
The noValidate attribute disables browser-native validation, giving you full control over error display. The aria-invalid attribute and role="alert" on error messages ensure screen readers announce validation errors.
Real-Time and Conditional Validation
Forms often require dynamic validation rules that depend on other field values. Zod's refine and superRefine methods handle cross-field validation:
const registrationSchema = z.object({
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string(),
acceptTerms: z.boolean().refine((val) => val === true, {
message: 'You must accept the terms and conditions',
}),
referralCode: z.string().optional(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
export const paymentSchema = z.discriminatedUnion('paymentMethod', [
z.object({
paymentMethod: z.literal('card'),
cardNumber: z.string().regex(/^\d{16}$/, 'Invalid card number'),
expiry: z.string().regex(/^\d{2}\/\d{2}$/, 'Use MM/YY format'),
cvv: z.string().regex(/^\d{3,4}$/, 'Invalid CVV'),
}),
z.object({
paymentMethod: z.literal('bank_transfer'),
accountNumber: z.string().min(8, 'Invalid account number'),
}),
])
Discriminated unions let you switch the entire validation structure based on a field value—perfect for multi-step checkout forms where payment details change by method.
Server-Side Validation with the Same Schema
The same Zod schema validates incoming API requests, creating a zero-overlap validation boundary:
// app/api/contact/route.ts
import { contactFormSchema } from '@/lib/schemas'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const body = await req.json()
const result = contactFormSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 422 }
)
}
// result.data is fully typed as ContactFormData
await sendEmail(result.data)
return NextResponse.json({ success: true })
}
safeParse returns a discriminated union with either data or error, avoiding try-catch flow control. The flatten() method transforms nested error paths into a simple fieldErrors object that the client can display directly.
Zod and React Hook Form give you type-safe, validated forms that share logic between client and server. The result is fewer bugs, faster form interactions, and a single source of truth for all validation rules.
<a href="/services/web-development">Our development team builds production React applications</a> with robust form validation and type-safe APIs. Contact us for help with your next project.
Related Insights

Building Accessible React Applications: WCAG 2.2 Compliance Guide
A guide to building WCAG 2.2 compliant React applications including semantic HTML, ARIA attributes, keyboard navigation, focus management, and automated accessibility testing.

Code Splitting and Lazy Loading in React: Performance Optimization Guide
A comprehensive guide to code splitting and lazy loading in React applications including React.lazy, Suspense, route-based splitting, and component-level chunking.

Building a Design System with React, TypeScript, and Storybook
A complete guide to building a scalable design system using React, TypeScript, and Storybook including component architecture, theming, accessibility, and documentation.