| name | react-hook-form-zod |
| description | 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.
|
| license | MIT |
React Hook Form + Zod Validation
Status: Production Ready ✅
Last Updated: 2025-11-20
Dependencies: None (standalone)
Latest Versions: react-hook-form@7.66.1, zod@4.1.12, @hookform/resolvers@5.2.2
Quick Start (10 Minutes)
1. Install Packages
npm install react-hook-form@7.66.1 zod@4.1.12 @hookform/resolvers@5.2.2
Why These Packages:
- react-hook-form: Performant, flexible form library with minimal re-renders
- zod: TypeScript-first schema validation with type inference
- @hookform/resolvers: Adapter to connect Zod (and other validators) to React Hook Form
2. Create Your First Form
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
type LoginFormData = z.infer<typeof loginSchema>
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
})
const onSubmit = async (data: LoginFormData) => {
console.log('Valid data:', data)
}
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>
)
}
CRITICAL:
- Always set
defaultValues to prevent "uncontrolled to controlled" warnings
- Use
zodResolver(schema) to connect Zod validation
- Type form with
z.infer<typeof schema> for full type safety
- Validate on both client AND server (never trust client validation alone)
3. Add Server-Side Validation
import { z } from 'zod'
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 {
const data = loginSchema.parse(await req.json())
return { success: true }
} catch (error) {
if (error instanceof z.ZodError) {
return { success: false, errors: error.flatten().fieldErrors }
}
throw error
}
}
Why Server Validation:
- Client validation can be bypassed (inspect element, Postman, curl)
- Server validation is your security layer
- Same Zod schema = single source of truth
- Type safety across frontend and backend
Core Concepts
useForm Hook Anatomy
const {
register,
handleSubmit,
watch,
formState,
setValue,
getValues,
reset,
trigger,
control,
} = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onSubmit',
defaultValues: {},
})
useForm Options:
| Option | Description | Default |
|---|
resolver | Validation resolver (e.g., zodResolver) | undefined |
mode | When to validate ('onSubmit', 'onChange', 'onBlur', 'all') | 'onSubmit' |
reValidateMode | When to re-validate after error | 'onChange' |
defaultValues | Initial form values | {} |
shouldUnregister | Unregister inputs when unmounted | false |
criteriaMode | Return all errors or first error only | 'firstError' |
Form Validation Modes:
onSubmit - Validate on submit (best performance, less responsive)
onChange - Validate on every change (live feedback, more re-renders)
onBlur - Validate when field loses focus (good balance)
all - Validate on submit, blur, and change (most responsive, highest cost)
Zod Schema Definition
import { z } from 'zod'
const stringSchema = z.string()
const numberSchema = z.number()
const booleanSchema = z.boolean()
const dateSchema = z.date()
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_]+$/)
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().int().positive(),
})
const tagsSchema = z.array(z.string())
const usersSchema = z.array(userSchema)
const optionalField = z.string().optional()
const nullableField = z.string().nullable()
const nullishField = z.string().nullish()
const withDefault = z.string().default('default value')
const statusSchema = z.union([
z.literal('active'),
z.literal('inactive'),
z.literal('pending'),
])
const statusEnum = z.enum(['active', 'inactive', 'pending'])
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,
})
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' })
Type Inference:
const userSchema = z.object({
name: z.string(),
age: z.number(),
})
type User = z.infer<typeof userSchema>
Zod Refinements (Custom Validation)
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'],
})
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'],
})
const usernameSchema = z.string().refine(async (username) => {
const response = await fetch(`/api/check-username?username=${username}`)
const { available } = await response.json()
return available
}, {
message: 'Username is already taken',
})
Zod Transforms (Data Manipulation)
const ageSchema = z.string().transform((val) => parseInt(val, 10))
const uppercaseSchema = z.string().transform((val) => val.toUpperCase())
const dateSchema = z.string().transform((val) => new Date(val))
const trimmedSchema = z.string().transform((val) => val.trim())
const userInputSchema = z.object({
email: z.string().email().transform((val) => val.toLowerCase()),
tags: z.string().transform((val) => val.split(',').map(tag => tag.trim())),
})
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' })
zodResolver Integration
import { zodResolver } from '@hookform/resolvers/zod'
const form = useForm<FormData>({
resolver: zodResolver(schema),
})
What zodResolver Does:
- Takes your Zod schema
- Converts it to a format React Hook Form understands
- Provides validation function that runs on form submission
- Maps Zod errors to React Hook Form error format
- Preserves type safety with TypeScript inference
zodResolver Options:
import { zodResolver } from '@hookform/resolvers/zod'
const form = useForm({
resolver: zodResolver(schema, {
async: false,
raw: false,
}),
})
Form Registration Patterns
Pattern 1: Simple Input Registration
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>
)
}
What register() Returns:
{
onChange: (e) => void,
onBlur: (e) => void,
ref: (instance) => void,
name: string,
}
Pattern 2: Controller (for Custom Components)
Use Controller when the input doesn't expose ref (like custom components, React Select, date pickers, etc.):
import { 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>
)
}
When to Use Controller:
- ✅ Third-party UI libraries (React Select, Material-UI, Ant Design, etc.)
- ✅ Custom components that don't expose ref
- ✅ Components that don't use onChange (like checkboxes with custom handlers)
- ✅ Need fine-grained control over field behavior
When NOT to Use Controller:
- ❌ Standard HTML inputs (use
register instead - it's simpler and faster)
- ❌ When performance is critical (Controller adds minimal overhead)
Pattern 3: useController (Reusable Controlled Inputs)
import { useController } from 'react-hook-form'
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>
)
}
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>
)
}
Error Handling
Displaying Errors
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>
)
}
Error Object Structure
{
email: {
type: 'invalid_string',
message: 'Invalid email address',
},
password: {
type: 'too_small',
message: 'Password must be at least 8 characters',
},
address: {
street: {
type: 'invalid_type',
message: 'Expected string, received undefined',
},
},
}
Form-Level Validation Errors
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'],
})
.refine((data) => someCondition, {
message: 'Form validation failed',
})
const { formState: { errors } } = useForm()
errors.root?.message
Server Errors Integration
function 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()
Object.entries(serverErrors).forEach(([field, message]) => {
setError(field, {
type: 'server',
message,
})
})
return
}
} catch (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>
)
}
Advanced Patterns
Dynamic Form Fields (useFieldArray)
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>
)
}
useFieldArray API:
fields - Array of field items with unique IDs
append(value) - Add new item to end
prepend(value) - Add new item to beginning
insert(index, value) - Insert item at index
remove(index) - Remove item at index
update(index, value) - Update item at index
replace(values) - Replace entire array
Async Validation with Debouncing
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useDebouncedCallback } from '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',
})
const debouncedValidation = useDebouncedCallback(() => {
trigger('username')
}, 500)
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>
)
}
Multi-Step Form (Wizard)
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
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'),
})
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']
}
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>
)
}
Conditional Validation
import { z } from 'zod'
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}$/),
}),
])
const conditionalSchema = z.object({
hasDiscount: z.boolean(),
discountCode: z.string().optional(),
}).refine((data) => {
if (data.hasDiscount && !data.discountCode) {
return false
}
return true
}, {
message: 'Discount code is required when discount is enabled',
path: ['discountCode'],
})
shadcn/ui Integration
Using Form Component (Legacy)
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>
)
}
Note: shadcn/ui states "We are not actively developing the Form component anymore." They recommend using the Field component for new implementations.
Using Field Component (Recommended)
Check shadcn/ui documentation for the latest Field component API as it's the actively maintained approach.
Performance Optimization
Form Mode Strategies
const form = useForm({
mode: 'onSubmit',
resolver: zodResolver(schema),
})
const form = useForm({
mode: 'onBlur',
resolver: zodResolver(schema),
})
const form = useForm({
mode: 'onChange',
resolver: zodResolver(schema),
})
const form = useForm({
mode: 'all',
resolver: zodResolver(schema),
})
Controlled vs Uncontrolled Inputs
<input {...register('email')} />
<Controller
name="email"
control={control}
render={({ field }) => <Input {...field} />}
/>
Recommendation: Use register for standard inputs, Controller only when necessary (third-party components, custom behavior).
Isolation with Controller
function BadForm() {
const { watch } = useForm()
const values = watch()
return <div>{JSON.stringify(values)}</div>
}
function GoodForm() {
const { watch } = useForm()
const email = watch('email')
return <div>{email}</div>
}
shouldUnregister Flag
const form = useForm({
resolver: zodResolver(schema),
shouldUnregister: true,
})
When to use:
- ✅ Multi-step forms where steps have different fields
- ✅ Conditional fields that should not persist
- ✅ Want to clear data when component unmounts
When NOT to use:
- ❌ Want to preserve form data when toggling visibility
- ❌ Navigating between form sections (tabs, accordions)
Accessibility Best Practices
ARIA Attributes
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>
)
}
Error Announcements
import { useEffect } from 'react'
function FormWithAnnouncements() {
const { formState: { errors, isSubmitted } } = useForm()
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' : ''}`
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>
)
}
Focus Management
import { useRef, useEffect } from 'react'
function FormWithFocus() {
const { handleSubmit, formState: { errors } } = useForm()
const firstErrorRef = useRef<HTMLInputElement>(null)
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>
)
}
Critical Rules
Always Do
✅ Set defaultValues to prevent "uncontrolled to controlled" warnings
const form = useForm({
defaultValues: { email: '', password: '' },
})
✅ Use zodResolver for Zod integration
const form = useForm({
resolver: zodResolver(schema),
})
✅ Type forms with z.infer
type FormData = z.infer<typeof schema>
✅ Validate on both client AND server
const form = useForm({ resolver: zodResolver(schema) })
const data = schema.parse(await req.json())
✅ Use formState.errors for error display
{errors.email && <span role="alert">{errors.email.message}</span>}
✅ Add ARIA attributes for accessibility
<input
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby="email-error"
/>
✅ Use field.id for useFieldArray keys
{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}
✅ Debounce async validation
const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)
Never Do
❌ Skip server-side validation (security vulnerability!)
const form = useForm({ resolver: zodResolver(schema) })
const form = useForm({ resolver: zodResolver(schema) })
❌ Use Zod v4 without checking type inference
❌ Forget to spread {...field} in Controller
<Controller render={({ field }) => <Input value={field.value} />} />
<Controller render={({ field }) => <Input {...field} />} />
❌ Mutate form values directly
const values = getValues()
values.email = 'new@email.com'
setValue('email', 'new@email.com')
❌ Use inline validation without debouncing
const form = useForm({ mode: 'onChange' })
const debouncedTrigger = useDebouncedCallback(() => trigger(), 500)
❌ Mix controlled and uncontrolled inputs
<input {...register('email')} value={email} onChange={setEmail} />
<input {...register('email')} />
<Controller render={({ field }) => <Input {...field} />} />
❌ Use index as key in useFieldArray
{fields.map((field, index) => <div key={index}>{/* ... */}</div>)}
{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}
❌ Forget defaultValues for all fields
const form = useForm({
resolver: zodResolver(schema),
})
const form = useForm({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '', remember: false },
})
Known Issues Prevention
This skill prevents 12 documented issues:
Issue #1: Zod v4 Type Inference Errors
Error: Type inference doesn't work correctly with Zod v4
Source: GitHub Issue #13109 (Closed 2025-11-01)
Why It Happens: Zod v4 changed how types are inferred
Prevention: Use correct type patterns: type FormData = z.infer<typeof schema>
Note: Resolved in react-hook-form v7.66.x+. Upgrade to latest version to avoid this issue.
Issue #2: Uncontrolled to Controlled Warning
Error: "A component is changing an uncontrolled input to be controlled"
Source: React documentation
Why It Happens: Not setting defaultValues causes undefined -> value transition
Prevention: Always set defaultValues for all fields
Issue #3: Nested Object Validation Errors
Error: Errors for nested fields don't display correctly
Source: Common React Hook Form issue
Why It Happens: Accessing nested errors incorrectly
Prevention: Use optional chaining: errors.address?.street?.message
Issue #4: Array Field Re-renders
Error: Form re-renders excessively with array fields
Source: Performance issue
Why It Happens: Not using field.id as key
Prevention: Use key={field.id} in useFieldArray map
Issue #5: Async Validation Race Conditions
Error: Multiple validation requests cause conflicting results
Source: Common async pattern issue
Why It Happens: No debouncing or request cancellation
Prevention: Debounce validation and cancel pending requests
Issue #6: Server Error Mapping
Error: Server validation errors don't map to form fields
Source: Integration issue
Why It Happens: Server error format doesn't match React Hook Form format
Prevention: Use setError() to map server errors to fields
Issue #7: Default Values Not Applied
Error: Form fields don't show default values
Source: Common mistake
Why It Happens: defaultValues set after form initialization
Prevention: Set defaultValues in useForm options, not useState
Issue #8: Controller Field Not Updating
Error: Custom component doesn't update when value changes
Source: Common Controller issue
Why It Happens: Not spreading {...field} in render function
Prevention: Always spread {...field} to custom component
Issue #9: useFieldArray Key Warnings
Error: React warning about duplicate keys in list
Source: React list rendering
Why It Happens: Using array index as key instead of field.id
Prevention: Use field.id: key={field.id}
Issue #10: Schema Refinement Error Paths
Error: Custom validation errors appear at wrong field
Source: Zod refinement behavior
Why It Happens: Not specifying path in refinement options
Prevention: Add path option: refine(..., { message: '...', path: ['fieldName'] })
Issue #11: Transform vs Preprocess Confusion
Error: Data transformation doesn't work as expected
Source: Zod API confusion
Why It Happens: Using wrong method for use case
Prevention: Use transform for output transformation, preprocess for input transformation
Issue #12: Multiple Resolver Conflicts
Error: Form validation doesn't work with multiple resolvers
Source: Configuration error
Why It Happens: Trying to use multiple validation libraries
Prevention: Use single resolver (zodResolver), combine schemas if needed
Templates
See the templates/ directory for working examples:
- basic-form.tsx - Simple login/signup form
- advanced-form.tsx - Nested objects, arrays, conditional fields
- shadcn-form.tsx - shadcn/ui Form component integration
- server-validation.ts - Server-side validation with same schema
- async-validation.tsx - Async validation with debouncing
- dynamic-fields.tsx - useFieldArray for adding/removing items
- multi-step-form.tsx - Wizard with per-step validation
- custom-error-display.tsx - Custom error formatting
- package.json - Complete dependencies
References
See the references/ directory for deep-dive documentation:
- zod-schemas-guide.md - Comprehensive Zod schema patterns
- rhf-api-reference.md - Complete React Hook Form API
- error-handling.md - Error messages, formatting, accessibility
- accessibility.md - WCAG compliance, ARIA attributes
- performance-optimization.md - Form modes, validation strategies
- shadcn-integration.md - shadcn/ui Form vs Field components
- top-errors.md - 12 common errors with solutions
- links-to-official-docs.md - Organized documentation links
Official Documentation
License: MIT
Last Verified: 2025-11-20
Maintainer: Jeremy Dawes (jeremy@jezweb.net)