Loading...
Loading...
Build type-safe validated forms in React using React Hook Form and Zod schema validation. Single schema works on both client and server for DRY validation with full TypeScript type inference via z.infer. Use when: building forms with validation, integrating shadcn/ui Form components, implementing multi-step wizards, handling dynamic field arrays with useFieldArray, or fixing uncontrolled to controlled warnings, resolver errors, async validation issues.
npx skill4agent add ovachiever/droid-tings react-hook-form-zodnpm install react-hook-form@7.66.1 zod@4.1.12 @hookform/resolvers@5.2.2import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// 1. Define validation schema
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
// 2. Infer TypeScript type from schema
type LoginFormData = z.infer<typeof loginSchema>
function LoginForm() {
// 3. Initialize form with zodResolver
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
})
// 4. Handle form submission
const onSubmit = async (data: LoginFormData) => {
// Data is guaranteed to be valid here
console.log('Valid data:', data)
// Make API call, etc.
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<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>
<div>
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register('password')} />
{errors.password && (
<span role="alert" className="error">
{errors.password.message}
</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Login'}
</button>
</form>
)
}defaultValueszodResolver(schema)z.infer<typeof schema>// server/api/login.ts
import { z } from 'zod'
// SAME schema on server
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
export async function loginHandler(req: Request) {
try {
// Parse and validate request body
const data = loginSchema.parse(await req.json())
// Data is type-safe and validated
// Proceed with authentication logic
return { success: true }
} catch (error) {
if (error instanceof z.ZodError) {
// Return validation errors to client
return { success: false, errors: error.flatten().fieldErrors }
}
throw error
}
}const {
register, // Register input fields
handleSubmit, // Wrap onSubmit handler
watch, // Watch field values
formState, // Form state (errors, isValid, isDirty, etc.)
setValue, // Set field value programmatically
getValues, // Get current form values
reset, // Reset form to defaults
trigger, // Trigger validation manually
control, // Control object for Controller/useController
} = useForm<FormData>({
resolver: zodResolver(schema), // Validation resolver
mode: 'onSubmit', // When to validate (onSubmit, onChange, onBlur, all)
defaultValues: {}, // Initial values (REQUIRED for controlled inputs)
})| Option | Description | Default |
|---|---|---|
| Validation resolver (e.g., zodResolver) | undefined |
| When to validate ('onSubmit', 'onChange', 'onBlur', 'all') | 'onSubmit' |
| When to re-validate after error | 'onChange' |
| Initial form values | {} |
| Unregister inputs when unmounted | false |
| Return all errors or first error only | 'firstError' |
onSubmitonChangeonBlurallimport { z } from 'zod'
// Primitives
const stringSchema = z.string()
const numberSchema = z.number()
const booleanSchema = z.boolean()
const dateSchema = z.date()
// With validation
const emailSchema = z.string().email('Invalid email')
const ageSchema = z.number().min(18, 'Must be 18+').max(120, 'Invalid age')
const usernameSchema = z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/)
// Objects
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().int().positive(),
})
// Arrays
const tagsSchema = z.array(z.string())
const usersSchema = z.array(userSchema)
// Optional and Nullable
const optionalField = z.string().optional() // string | undefined
const nullableField = z.string().nullable() // string | null
const nullishField = z.string().nullish() // string | null | undefined
// Default values
const withDefault = z.string().default('default value')
// Unions
const statusSchema = z.union([
z.literal('active'),
z.literal('inactive'),
z.literal('pending'),
])
// Shorthand for literals
const statusEnum = z.enum(['active', 'inactive', 'pending'])
// Nested objects
const addressSchema = z.object({
street: z.string(),
city: z.string(),
zipCode: z.string().regex(/^\d{5}$/),
})
const profileSchema = z.object({
name: z.string(),
address: addressSchema, // Nested object
})
// Custom error messages
const passwordSchema = z.string()
.min(8, { message: 'Password must be at least 8 characters' })
.regex(/[A-Z]/, { message: 'Password must contain uppercase letter' })
.regex(/[0-9]/, { message: 'Password must contain number' })const userSchema = z.object({
name: z.string(),
age: z.number(),
})
// Automatically infer TypeScript type
type User = z.infer<typeof userSchema>
// Result: { name: string; age: number }// Simple refinement
const passwordConfirmSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'], // Error will appear on confirmPassword field
})
// Multiple refinements
const signupSchema = z.object({
username: z.string(),
email: z.string().email(),
age: z.number(),
})
.refine((data) => data.username !== data.email.split('@')[0], {
message: 'Username cannot be your email prefix',
path: ['username'],
})
.refine((data) => data.age >= 18, {
message: 'Must be 18 or older',
path: ['age'],
})
// Async refinement (for API checks)
const usernameSchema = z.string().refine(async (username) => {
// Check if username is available via API
const response = await fetch(`/api/check-username?username=${username}`)
const { available } = await response.json()
return available
}, {
message: 'Username is already taken',
})// Transform string to number
const ageSchema = z.string().transform((val) => parseInt(val, 10))
// Transform to uppercase
const uppercaseSchema = z.string().transform((val) => val.toUpperCase())
// Transform date string to Date object
const dateSchema = z.string().transform((val) => new Date(val))
// Trim whitespace
const trimmedSchema = z.string().transform((val) => val.trim())
// Complex transform
const userInputSchema = z.object({
email: z.string().email().transform((val) => val.toLowerCase()),
tags: z.string().transform((val) => val.split(',').map(tag => tag.trim())),
})
// Chain transform and refine
const positiveNumberSchema = z.string()
.transform((val) => parseFloat(val))
.refine((val) => !isNaN(val), { message: 'Must be a number' })
.refine((val) => val > 0, { message: 'Must be positive' })import { zodResolver } from '@hookform/resolvers/zod'
const form = useForm<FormData>({
resolver: zodResolver(schema),
})import { zodResolver } from '@hookform/resolvers/zod'
// With options
const form = useForm({
resolver: zodResolver(schema, {
async: false, // Use async validation
raw: false, // Return raw Zod error
}),
})function BasicForm() {
const { register, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Spread register result to input */}
<input {...register('email')} />
<input {...register('password')} />
{/* With custom props */}
<input
{...register('username')}
placeholder="Enter username"
className="input"
/>
</form>
)
}register(){
onChange: (e) => void,
onBlur: (e) => void,
ref: (instance) => void,
name: string,
}Controllerrefimport { Controller } from 'react-hook-form'
function FormWithCustomInput() {
const { control, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="category"
control={control}
render={({ field }) => (
<CustomSelect
{...field} // value, onChange, onBlur, ref
options={categoryOptions}
/>
)}
/>
{/* With more control */}
<Controller
name="dateOfBirth"
control={control}
render={({ field, fieldState }) => (
<div>
<DatePicker
selected={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
{fieldState.error && (
<span>{fieldState.error.message}</span>
)}
</div>
)}
/>
</form>
)
}registerimport { useController } from 'react-hook-form'
// Reusable custom input component
function CustomInput({ name, control, label }) {
const {
field,
fieldState: { error },
} = useController({
name,
control,
defaultValue: '',
})
return (
<div>
<label>{label}</label>
<input {...field} />
{error && <span>{error.message}</span>}
</div>
)
}
// Usage
function MyForm() {
const { control, handleSubmit } = useForm({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<CustomInput name="email" control={control} label="Email" />
<CustomInput name="username" control={control} label="Username" />
</form>
)
}function FormWithErrors() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register('email')} aria-invalid={errors.email ? 'true' : 'false'} />
{/* Simple error display */}
{errors.email && <span>{errors.email.message}</span>}
{/* Accessible error display */}
{errors.email && (
<span role="alert" className="error">
{errors.email.message}
</span>
)}
{/* Error with icon */}
{errors.email && (
<div role="alert" className="error">
<ErrorIcon />
<span>{errors.email.message}</span>
</div>
)}
</div>
</form>
)
}// errors object structure
{
email: {
type: 'invalid_string',
message: 'Invalid email address',
},
password: {
type: 'too_small',
message: 'Password must be at least 8 characters',
},
// Nested errors
address: {
street: {
type: 'invalid_type',
message: 'Expected string, received undefined',
},
},
}const schema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'], // Attach error to confirmPassword field
})
// Without path - creates root error
.refine((data) => someCondition, {
message: 'Form validation failed',
})
// Access root errors
const { formState: { errors } } = useForm()
errors.root?.message // Root-level errorfunction FormWithServerErrors() {
const { register, handleSubmit, setError, formState: { errors } } = useForm({
resolver: zodResolver(schema),
})
const onSubmit = async (data) => {
try {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
})
if (!response.ok) {
const { errors: serverErrors } = await response.json()
// Map server errors to form fields
Object.entries(serverErrors).forEach(([field, message]) => {
setError(field, {
type: 'server',
message,
})
})
return
}
// Success!
} catch (error) {
// Generic error
setError('root', {
type: 'server',
message: 'An error occurred. Please try again.',
})
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root && <div role="alert">{errors.root.message}</div>}
{/* ... */}
</form>
)
}import { useForm, useFieldArray } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const contactSchema = z.object({
contacts: z.array(
z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
})
).min(1, 'At least one contact is required'),
})
type ContactFormData = z.infer<typeof contactSchema>
function ContactListForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
defaultValues: {
contacts: [{ name: '', email: '' }],
},
})
const { fields, append, remove } = useFieldArray({
control,
name: 'contacts',
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id}> {/* IMPORTANT: Use field.id, not index */}
<input
{...register(`contacts.${index}.name` as const)}
placeholder="Name"
/>
{errors.contacts?.[index]?.name && (
<span>{errors.contacts[index].name.message}</span>
)}
<input
{...register(`contacts.${index}.email` as const)}
placeholder="Email"
/>
{errors.contacts?.[index]?.email && (
<span>{errors.contacts[index].email.message}</span>
)}
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => append({ name: '', email: '' })}
>
Add Contact
</button>
<button type="submit">Submit</button>
</form>
)
}fieldsappend(value)prepend(value)insert(index, value)remove(index)update(index, value)replace(values)import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useDebouncedCallback } from 'use-debounce' // npm install use-debounce
const usernameSchema = z.string().min(3).refine(async (username) => {
const response = await fetch(`/api/check-username?username=${username}`)
const { available } = await response.json()
return available
}, {
message: 'Username is already taken',
})
function AsyncValidationForm() {
const { register, handleSubmit, trigger, formState: { errors, isValidating } } = useForm({
resolver: zodResolver(z.object({ username: usernameSchema })),
mode: 'onChange', // Validate on every change
})
// Debounce validation to avoid too many API calls
const debouncedValidation = useDebouncedCallback(() => {
trigger('username')
}, 500) // Wait 500ms after user stops typing
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('username')}
onChange={(e) => {
register('username').onChange(e)
debouncedValidation()
}}
/>
{isValidating && <span>Checking availability...</span>}
{errors.username && <span>{errors.username.message}</span>}
</form>
)
}import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// Step schemas
const step1Schema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
})
const step2Schema = z.object({
address: z.string().min(1, 'Address is required'),
city: z.string().min(1, 'City is required'),
})
const step3Schema = z.object({
cardNumber: z.string().regex(/^\d{16}$/, 'Invalid card number'),
cvv: z.string().regex(/^\d{3,4}$/, 'Invalid CVV'),
})
// Combined schema for final validation
const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema)
type FormData = z.infer<typeof fullSchema>
function MultiStepForm() {
const [step, setStep] = useState(1)
const { register, handleSubmit, trigger, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(fullSchema),
mode: 'onChange',
})
const nextStep = async () => {
let fieldsToValidate: (keyof FormData)[] = []
if (step === 1) {
fieldsToValidate = ['name', 'email']
} else if (step === 2) {
fieldsToValidate = ['address', 'city']
}
// Validate current step fields
const isValid = await trigger(fieldsToValidate)
if (isValid) {
setStep(step + 1)
}
}
const prevStep = () => setStep(step - 1)
const onSubmit = (data: FormData) => {
console.log('Final data:', data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Progress indicator */}
<div className="progress">
Step {step} of 3
</div>
{/* Step 1 */}
{step === 1 && (
<div>
<h2>Personal Information</h2>
<input {...register('name')} placeholder="Name" />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} placeholder="Email" />
{errors.email && <span>{errors.email.message}</span>}
</div>
)}
{/* Step 2 */}
{step === 2 && (
<div>
<h2>Address</h2>
<input {...register('address')} placeholder="Address" />
{errors.address && <span>{errors.address.message}</span>}
<input {...register('city')} placeholder="City" />
{errors.city && <span>{errors.city.message}</span>}
</div>
)}
{/* Step 3 */}
{step === 3 && (
<div>
<h2>Payment</h2>
<input {...register('cardNumber')} placeholder="Card Number" />
{errors.cardNumber && <span>{errors.cardNumber.message}</span>}
<input {...register('cvv')} placeholder="CVV" />
{errors.cvv && <span>{errors.cvv.message}</span>}
</div>
)}
{/* Navigation */}
<div>
{step > 1 && (
<button type="button" onClick={prevStep}>
Previous
</button>
)}
{step < 3 ? (
<button type="button" onClick={nextStep}>
Next
</button>
) : (
<button type="submit">Submit</button>
)}
</div>
</form>
)
}import { z } from 'zod'
// Schema with conditional validation
const formSchema = z.discriminatedUnion('accountType', [
z.object({
accountType: z.literal('personal'),
name: z.string().min(1),
}),
z.object({
accountType: z.literal('business'),
companyName: z.string().min(1),
taxId: z.string().regex(/^\d{9}$/),
}),
])
// Alternative: Using refine
const conditionalSchema = z.object({
hasDiscount: z.boolean(),
discountCode: z.string().optional(),
}).refine((data) => {
// If hasDiscount is true, discountCode is required
if (data.hasDiscount && !data.discountCode) {
return false
}
return true
}, {
message: 'Discount code is required when discount is enabled',
path: ['discountCode'],
})import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
const formSchema = z.object({
username: z.string().min(2, 'Username must be at least 2 characters'),
email: z.string().email('Invalid email address'),
})
function ProfileForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: '',
email: '',
},
})
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>
This is your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="email@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button type="submit">Submit</button>
</form>
</Form>
)
}// Best performance - validate only on submit
const form = useForm({
mode: 'onSubmit',
resolver: zodResolver(schema),
})
// Good balance - validate on blur
const form = useForm({
mode: 'onBlur',
resolver: zodResolver(schema),
})
// Live feedback - validate on every change
const form = useForm({
mode: 'onChange',
resolver: zodResolver(schema),
})
// Maximum validation - all events
const form = useForm({
mode: 'all',
resolver: zodResolver(schema),
})// Uncontrolled (better performance) - use register
<input {...register('email')} />
// Controlled (more control) - use Controller
<Controller
name="email"
control={control}
render={({ field }) => <Input {...field} />}
/>registerController// BAD: Entire form re-renders when any field changes
function BadForm() {
const { watch } = useForm()
const values = watch() // Watches ALL fields
return <div>{JSON.stringify(values)}</div>
}
// GOOD: Only re-render when specific field changes
function GoodForm() {
const { watch } = useForm()
const email = watch('email') // Watches only email field
return <div>{email}</div>
}const form = useForm({
resolver: zodResolver(schema),
shouldUnregister: true, // Remove field data when unmounted
})function AccessibleForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email.message}
</span>
)}
</div>
</form>
)
}import { useEffect } from 'react'
function FormWithAnnouncements() {
const { formState: { errors, isSubmitted } } = useForm()
// Announce errors to screen readers
useEffect(() => {
if (isSubmitted && Object.keys(errors).length > 0) {
const errorCount = Object.keys(errors).length
const announcement = `Form submission failed with ${errorCount} error${errorCount > 1 ? 's' : ''}`
// Create live region for announcement
const liveRegion = document.createElement('div')
liveRegion.setAttribute('role', 'alert')
liveRegion.setAttribute('aria-live', 'assertive')
liveRegion.textContent = announcement
document.body.appendChild(liveRegion)
setTimeout(() => {
document.body.removeChild(liveRegion)
}, 1000)
}
}, [errors, isSubmitted])
return (
<form>
{/* ... */}
</form>
)
}import { useRef, useEffect } from 'react'
function FormWithFocus() {
const { handleSubmit, formState: { errors } } = useForm()
const firstErrorRef = useRef<HTMLInputElement>(null)
// Focus first error field on validation failure
useEffect(() => {
if (Object.keys(errors).length > 0) {
firstErrorRef.current?.focus()
}
}, [errors])
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email')}
ref={errors.email ? firstErrorRef : undefined}
/>
</form>
)
}const form = useForm({
defaultValues: { email: '', password: '' }, // ALWAYS set defaults
})const form = useForm({
resolver: zodResolver(schema), // Required for Zod validation
})type FormData = z.infer<typeof schema> // Automatic type inference// Client
const form = useForm({ resolver: zodResolver(schema) })
// Server
const data = schema.parse(await req.json()) // SAME schema{errors.email && <span role="alert">{errors.email.message}</span>}<input
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby="email-error"
/>{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)// BAD: Only client validation
const form = useForm({ resolver: zodResolver(schema) })
// API endpoint has no validation
// GOOD: Validate on both client and server
const form = useForm({ resolver: zodResolver(schema) })
// API: schema.parse(data) on server too// Issue #13109: Zod v4 has type inference changes
// Test your types carefully when upgrading// BAD
<Controller render={({ field }) => <Input value={field.value} />} />
// GOOD
<Controller render={({ field }) => <Input {...field} />} />// BAD
const values = getValues()
values.email = 'new@email.com' // Direct mutation
// GOOD
setValue('email', 'new@email.com') // Use setValue// BAD: Validates on every keystroke
const form = useForm({ mode: 'onChange' })
// GOOD: Debounce async validation
const debouncedTrigger = useDebouncedCallback(() => trigger(), 500)// BAD: Mixing patterns
<input {...register('email')} value={email} onChange={setEmail} />
// GOOD: Choose one pattern
<input {...register('email')} /> // Uncontrolled
// OR
<Controller render={({ field }) => <Input {...field} />} /> // Controlled// BAD
{fields.map((field, index) => <div key={index}>{/* ... */}</div>)}
// GOOD
{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}// BAD: Missing defaults causes warnings
const form = useForm({
resolver: zodResolver(schema),
})
// GOOD: Set defaults for all fields
const form = useForm({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '', remember: false },
})type FormData = z.infer<typeof schema>errors.address?.street?.messagekey={field.id}key={field.id}refine(..., { message: '...', path: ['fieldName'] })templates/references/