
React Hook Form Zod
- 638 installs
- 51 repo stars
- Updated November 25, 2025
- ovachiever/droid-tings
react-hook-form-zod is a Claude agent skill at version 1.0.0 that builds type-safe React forms using React Hook Form and a single Zod schema shared between client and server with full TypeScript inference via z.infer.
About
react-hook-form-zod is a version 1.0.0 MIT-licensed skill for production React forms validated with Zod and wired through React Hook Form resolvers. A single Zod schema drives both client and server validation so rules stay DRY, and z.infer supplies end-to-end TypeScript types for field values and errors. The skill covers shadcn/ui Form component integration, multi-step wizards, dynamic field arrays via useFieldArray, and fixes for common pitfalls like uncontrolled-to-controlled warnings, resolver mismatches, and async validation failures. Developers reach for react-hook-form-zod when building signup flows, settings panels, or wizard UIs that must share validation logic with API route handlers or server actions without duplicating schemas.
- Single Zod schema powers both client and server validation
- Full TypeScript inference via z.infer with zero duplication
- Seamless integration with shadcn/ui Form components
- Handles useFieldArray, multi-step wizards, and dynamic forms
- Prevents 12 documented form errors and saves ~60% tokens
React Hook Form Zod by the numbers
- 638 all-time installs (skills.sh)
- +10 installs in the week ending Jul 26, 2026 (Skillselion tracking)
- Ranked #536 of 2,244 Frontend Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ovachiever/droid-tings --skill react-hook-form-zodAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 638 |
|---|---|
| repo stars | ★ 51 |
| Security audit | 3 / 3 scanners passed |
| Last updated | November 25, 2025 |
| Repository | ovachiever/droid-tings ↗ |
How do you share Zod validation between React forms and server?
Generate production-ready, type-safe React forms that stay DRY between client and server using a single Zod schema.
Who is it for?
React developers integrating React Hook Form with Zod and shadcn/ui who need one schema for client and server validation with full TypeScript inference.
Skip if: Teams using Formik, Yup-only stacks, or non-React frameworks without React Hook Form and Zod in the codebase.
When should I use this skill?
The user builds React forms with validation, integrates shadcn/ui Form, implements multi-step wizards, useFieldArray, or hits resolver and controlled-input errors.
What you get
Type-safe React form components, a shared Zod schema, resolver configuration, and matching server-side validation with z.infer TypeScript types.
- Shared Zod validation schema
- Type-safe React form components with resolver config
By the numbers
- Published at version 1.0.0 under MIT license
Files
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.2Why 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'
// 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>
)
}CRITICAL:
- Always set
defaultValuesto 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
// 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
}
}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, // 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)
})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'
// 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' })Type Inference:
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 }Zod Refinements (Custom Validation)
// 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',
})Zod Transforms (Data Manipulation)
// 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' })zodResolver Integration
import { zodResolver } from '@hookform/resolvers/zod'
const form = useForm<FormData>({
resolver: zodResolver(schema),
})What zodResolver Does: 1. Takes your Zod schema 2. Converts it to a format React Hook Form understands 3. Provides validation function that runs on form submission 4. Maps Zod errors to React Hook Form error format 5. Preserves type safety with TypeScript inference
zodResolver Options:
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
}),
})---
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
registerinstead - 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'
// 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>
)
}---
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
// 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',
},
},
}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'], // 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 errorServer 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()
// 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>
)
}---
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 IDsappend(value)- Add new item to endprepend(value)- Add new item to beginninginsert(index, value)- Insert item at indexremove(index)- Remove item at indexupdate(index, value)- Update item at indexreplace(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' // 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>
)
}Multi-Step Form (Wizard)
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>
)
}Conditional Validation
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'],
})---
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
// 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),
})Controlled vs Uncontrolled Inputs
// Uncontrolled (better performance) - use register
<input {...register('email')} />
// Controlled (more control) - use Controller
<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
// 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>
}shouldUnregister Flag
const form = useForm({
resolver: zodResolver(schema),
shouldUnregister: true, // Remove field data when unmounted
})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()
// 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>
)
}Focus Management
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>
)
}---
Critical Rules
Always Do
✅ Set defaultValues to prevent "uncontrolled to controlled" warnings
const form = useForm({
defaultValues: { email: '', password: '' }, // ALWAYS set defaults
})✅ Use zodResolver for Zod integration
const form = useForm({
resolver: zodResolver(schema), // Required for Zod validation
})✅ Type forms with z.infer
type FormData = z.infer<typeof schema> // Automatic type inference✅ Validate on both client AND server
// Client
const form = useForm({ resolver: zodResolver(schema) })
// Server
const data = schema.parse(await req.json()) // SAME schema✅ 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!)
// 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❌ Use Zod v4 without checking type inference
// Issue #13109: Zod v4 has type inference changes
// Test your types carefully when upgrading❌ Forget to spread {...field} in Controller
// BAD
<Controller render={({ field }) => <Input value={field.value} />} />
// GOOD
<Controller render={({ field }) => <Input {...field} />} />❌ Mutate form values directly
// BAD
const values = getValues()
values.email = 'new@email.com' // Direct mutation
// GOOD
setValue('email', 'new@email.com') // Use setValue❌ Use inline validation without debouncing
// BAD: Validates on every keystroke
const form = useForm({ mode: 'onChange' })
// GOOD: Debounce async validation
const debouncedTrigger = useDebouncedCallback(() => trigger(), 500)❌ Mix controlled and uncontrolled inputs
// 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❌ Use index as key in useFieldArray
// BAD
{fields.map((field, index) => <div key={index}>{/* ... */}</div>)}
// GOOD
{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}❌ Forget defaultValues for all fields
// 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 },
})---
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:
1. basic-form.tsx - Simple login/signup form 2. advanced-form.tsx - Nested objects, arrays, conditional fields 3. shadcn-form.tsx - shadcn/ui Form component integration 4. server-validation.ts - Server-side validation with same schema 5. async-validation.tsx - Async validation with debouncing 6. dynamic-fields.tsx - useFieldArray for adding/removing items 7. multi-step-form.tsx - Wizard with per-step validation 8. custom-error-display.tsx - Custom error formatting 9. package.json - Complete dependencies
---
References
See the references/ directory for deep-dive documentation:
1. zod-schemas-guide.md - Comprehensive Zod schema patterns 2. rhf-api-reference.md - Complete React Hook Form API 3. error-handling.md - Error messages, formatting, accessibility 4. accessibility.md - WCAG compliance, ARIA attributes 5. performance-optimization.md - Form modes, validation strategies 6. shadcn-integration.md - shadcn/ui Form vs Field components 7. top-errors.md - 12 common errors with solutions 8. links-to-official-docs.md - Organized documentation links
---
Official Documentation
- React Hook Form: https://react-hook-form.com/
- Zod: https://zod.dev/
- @hookform/resolvers: https://github.com/react-hook-form/resolvers
- shadcn/ui Form: https://ui.shadcn.com/docs/components/form
---
License: MIT Last Verified: 2025-11-20 Maintainer: Jeremy Dawes (jeremy@jezweb.net)
{
"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.",
"version": "1.0.0",
"author": {
"name": "Jeremy Dawes",
"email": "jeremy@jezweb.net"
},
"license": "MIT",
"repository": "https://github.com/jezweb/claude-skills",
"keywords": []
}
React Hook Form + Zod Validation
Status: Production Ready ✅ Last Updated: 2025-11-20 Production Tested: Multiple production applications Token Savings: ~60% Errors Prevented: 12 documented issues
---
Auto-Trigger Keywords
Claude Code automatically discovers this skill when you mention:
Primary Keywords
- react-hook-form
- useForm
- zod validation
- form validation
- zodResolver
- @hookform/resolvers
- rhf
- form schema
- zod schema
- react forms
Secondary Keywords
- register form
- handleSubmit
- formState errors
- form validation react
- useFieldArray
- useWatch
- useController
- Controller component
- form errors
- validation schema
- client side validation
- server side validation
- form error handling
- validation errors react
Framework Integration
- shadcn form
- shadcn/ui form
- Form component shadcn
- Field component shadcn
- next.js form validation
- react form validation
- vite form validation
Zod-Specific
- z.object
- z.string
- z.number
- z.array
- z.infer
- zod refine
- zod transform
- zod error messages
- schema validation
Error-Based Keywords
- "resolver not found"
- "zod type inference"
- "form validation failed"
- "schema validation error"
- "useForm types"
- "zod infer"
- "nested validation"
- "array field validation"
- "dynamic fields"
- "uncontrolled to controlled"
- "default values required"
- "field not updating"
- "resolver is not a function"
- "schema parse error"
---
What This Skill Does
This skill provides comprehensive knowledge for building type-safe, validated forms in React using React Hook Form + Zod:
- Complete React Hook Form API - useForm, register, Controller, useFieldArray, useWatch, useController
- Zod Schema Patterns - All data types, refinements, transforms, error customization
- shadcn/ui Integration - Form and Field component patterns
- Client + Server Validation - Dual validation with single source of truth
- Advanced Patterns - Dynamic fields, multi-step forms, async validation, nested objects, arrays
- Error Handling - Accessible error display, custom formatting, server error mapping
- Performance Optimization - Form modes, validation strategies, re-render optimization
- TypeScript Type Safety - Full type inference from Zod schemas
- Accessibility - WCAG compliance, ARIA attributes, keyboard navigation
---
Known Issues Prevented
This skill prevents 12 documented issues:
| Issue | Source | Prevention |
|---|---|---|
| Zod v4 type inference errors | #13109 (Closed) | Correct type patterns for Zod v4; resolved in v7.66.x+ |
| Uncontrolled to controlled warning | React docs | Always set defaultValues |
| Nested object validation errors | Common issue | Proper error path handling |
| Array field re-renders | Performance issue | useFieldArray optimization |
| Async validation race conditions | Common pattern | Debouncing and cancellation |
| Server error mapping | Integration issue | Error structure alignment |
| Default values not applied | Common mistake | Proper initialization |
| Controller field not updating | Common issue | Correct render function usage |
| useFieldArray key warnings | React warnings | Proper key prop usage |
| Schema refinement error paths | Zod behavior | Custom path specification |
| Transform vs preprocess confusion | Zod API | Clear usage guidelines |
| Multiple resolver conflicts | Configuration error | Single resolver pattern |
---
When to Use This Skill
✅ Use when:
- Building forms with validation in React
- Need type-safe form data
- Want client + server validation with single schema
- Using shadcn/ui components
- Need complex validation (nested objects, arrays, conditional)
- Require accessible form error handling
- Building multi-step forms or wizards
- Need dynamic form fields (add/remove items)
- Want performance-optimized forms
- Using Next.js, Vite, or any React framework
❌ Don't use when:
- Building simple forms without validation (plain React state is fine)
- Using different validation library (Yup, Joi, etc.)
- Using different form library (Formik, Final Form, etc.)
- Building non-React forms (use appropriate library)
---
Quick Example
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// 1. Define Zod schema
const formSchema = 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
type FormData = z.infer<typeof formSchema>
function LoginForm() {
// 3. Setup form with zodResolver
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: { email: '', password: '' },
})
// 4. Handle submission
const onSubmit = (data: FormData) => {
console.log('Valid data:', data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Login</button>
</form>
)
}---
Package Versions
Latest Tested Versions (as of 2025-11-20):
- react-hook-form: 7.66.1
- zod: 4.1.12
- @hookform/resolvers: 5.2.2
Installation:
npm install react-hook-form@7.66.1 zod@4.1.12 @hookform/resolvers@5.2.2---
Token Efficiency
Manual Setup (without skill):
- Initial implementation: ~6,000 tokens
- Debugging validation errors: ~3,000 tokens
- Adding advanced features: ~2,000 tokens
- Total: ~10,000 tokens
With Skill:
- Direct implementation: ~3,000 tokens
- Minimal debugging: ~500 tokens
- Advanced features: ~500 tokens
- Total: ~4,000 tokens
Savings: ~60% (6,000 tokens saved)
---
Templates Included
1. basic-form.tsx - Simple login/signup form 2. advanced-form.tsx - Nested objects, arrays, conditional fields 3. shadcn-form.tsx - shadcn/ui Form component integration 4. server-validation.ts - Server-side validation with same schema 5. async-validation.tsx - Async validation with debouncing 6. dynamic-fields.tsx - useFieldArray for adding/removing items 7. multi-step-form.tsx - Wizard with per-step validation 8. custom-error-display.tsx - Custom error formatting and display 9. package.json - Complete dependencies
---
Reference 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
---
Dependencies
- React 18+ or React 19+
- TypeScript 5+ (recommended)
- No other Claude Code skills required (standalone)
---
Production Validation
This skill has been tested in:
- ✅ React + Vite applications
- ✅ Next.js App Router with Server Actions
- ✅ Next.js Pages Router with API routes
- ✅ shadcn/ui Form component integration
- ✅ Complex validation scenarios (nested, arrays, async)
- ✅ TypeScript strict mode
---
Contributing
Found an issue or have a suggestion? Open an issue at: https://github.com/jezweb/claude-skills/issues
---
License: MIT Maintainer: Jeremy Dawes (jeremy@jezweb.net) Repository: https://github.com/jezweb/claude-skills
Accessibility (a11y) Best Practices
Complete guide for building accessible forms.
---
WCAG Compliance
Required Elements
1. Labels - Every input must have a label 2. Error Messages - Must be accessible to screen readers 3. Focus Management - Errors should be announced 4. Keyboard Navigation - Full keyboard support
---
ARIA Attributes
Essential ARIA
<input
id="email"
type="email"
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
aria-required="true"
/>
<span id="email-hint">We'll never share your email</span>
{errors.email && (
<span id="email-error" role="alert">
{errors.email.message}
</span>
)}Live Regions for Error Announcements
{Object.keys(errors).length > 0 && (
<div role="alert" aria-live="assertive" aria-atomic="true">
Form has {Object.keys(errors).length} errors. Please review.
</div>
)}---
Focus Management
Focus First Error
import { useEffect, useRef } from 'react'
const firstErrorRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (Object.keys(errors).length > 0) {
firstErrorRef.current?.focus()
}
}, [errors])
// In JSX
<input
ref={Object.keys(errors)[0] === 'email' ? firstErrorRef : undefined}
{...register('email')}
/>Using setFocus
const onSubmit = async (data) => {
try {
await submitData(data)
} catch (error) {
setFocus('email') // Focus field programmatically
}
}---
Label Association
Explicit Labels
<label htmlFor="email">Email Address</label>
<input id="email" {...register('email')} />aria-label (When Visual Label Not Possible)
<input
{...register('search')}
aria-label="Search products"
placeholder="Search..."
/>aria-labelledby (Multiple Labels)
<h3 id="billing-heading">Billing Address</h3>
<input
{...register('billingStreet')}
aria-labelledby="billing-heading billing-street-label"
/>
<span id="billing-street-label">Street</span>---
Required Fields
Visual Indicator
<label htmlFor="email">
Email <span aria-label="required">*</span>
</label>
<input
id="email"
{...register('email')}
aria-required="true"
required
/>Legend for Required Fields
<p className="required-legend">
<span aria-label="required">*</span> Required field
</p>---
Error Messaging
Accessible Error Pattern
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
{...register('password')}
aria-invalid={errors.password ? 'true' : 'false'}
aria-describedby={errors.password ? 'password-error' : 'password-hint'}
/>
<span id="password-hint" className="hint">
Must be at least 8 characters
</span>
{errors.password && (
<span id="password-error" role="alert" className="error">
{errors.password.message}
</span>
)}
</div>---
Fieldsets and Legends
Grouping Related Fields
<fieldset>
<legend>Contact Information</legend>
<div>
<label htmlFor="firstName">First Name</label>
<input id="firstName" {...register('firstName')} />
</div>
<div>
<label htmlFor="lastName">Last Name</label>
<input id="lastName" {...register('lastName')} />
</div>
</fieldset>Radio Groups
<fieldset>
<legend>Choose your plan</legend>
<div>
<input
id="plan-basic"
type="radio"
value="basic"
{...register('plan')}
/>
<label htmlFor="plan-basic">Basic</label>
</div>
<div>
<input
id="plan-pro"
type="radio"
value="pro"
{...register('plan')}
/>
<label htmlFor="plan-pro">Pro</label>
</div>
</fieldset>---
Keyboard Navigation
Tab Order
// Ensure logical tab order with tabindex (use sparingly)
<input {...register('email')} tabIndex={1} />
<input {...register('password')} tabIndex={2} />
<button type="submit" tabIndex={3}>Submit</button>Skip Links
<a href="#main-form" className="skip-link">
Skip to form
</a>
<form id="main-form">
{/* ... */}
</form>---
Button Accessibility
Submit Button States
<button
type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting ? 'true' : 'false'}
aria-live="polite"
>
{isSubmitting ? 'Submitting...' : 'Submit Form'}
</button>Icon Buttons
<button type="button" aria-label="Remove item" onClick={remove}>
<TrashIcon aria-hidden="true" />
</button>---
Screen Reader Announcements
Status Messages
{isSubmitSuccessful && (
<div role="status" aria-live="polite">
Form submitted successfully!
</div>
)}Loading States
{isSubmitting && (
<div role="status" aria-live="polite">
Submitting form, please wait...
</div>
)}---
Color Contrast
WCAG AA Standards
- Normal text: 4.5:1 minimum
- Large text: 3:1 minimum
- UI components: 3:1 minimum
/* Good contrast examples */
.error {
color: #c41e3a; /* Red */
background: #ffffff; /* White */
/* Contrast ratio: 5.77:1 ✓ */
}
.button {
color: #ffffff;
background: #0066cc;
/* Contrast ratio: 7.33:1 ✓ */
}---
Testing
Automated Testing Tools
- axe DevTools - Browser extension
- Lighthouse - Chrome DevTools
- WAVE - Web accessibility evaluation tool
Manual Testing
1. Keyboard Navigation - Tab through entire form 2. Screen Reader - Test with NVDA (Windows) or VoiceOver (Mac) 3. Zoom - Test at 200% zoom 4. High Contrast - Test in high contrast mode
---
Accessibility Checklist
- [ ] All inputs have associated labels
- [ ] Required fields are marked with aria-required
- [ ] Error messages use role="alert"
- [ ] Errors have aria-describedby linking to error text
- [ ] Form has clear heading structure
- [ ] Keyboard navigation works completely
- [ ] Focus is managed appropriately
- [ ] Color is not the only indicator of errors
- [ ] Contrast ratios meet WCAG AA standards
- [ ] Screen reader testing completed
---
Resources:
- WCAG Guidelines: https://www.w3.org/WAI/WCAG21/quickref/
- React Hook Form a11y: https://react-hook-form.com/advanced-usage#AccessibilityA11y
Error Handling Guide
Complete guide for handling and displaying form errors.
---
Error Display Patterns
1. Inline Errors (Recommended)
<input {...register('email')} />
{errors.email && (
<span role="alert" className="text-red-600">
{errors.email.message}
</span>
)}2. Error Summary (Accessibility Best Practice)
{Object.keys(errors).length > 0 && (
<div role="alert" aria-live="assertive" className="error-summary">
<h3>Please fix the following errors:</h3>
<ul>
{Object.entries(errors).map(([field, error]) => (
<li key={field}>
<strong>{field}:</strong> {error.message}
</li>
))}
</ul>
</div>
)}3. Toast Notifications
const onError = (errors) => {
toast.error(`Please fix ${Object.keys(errors).length} errors`)
}
<form onSubmit={handleSubmit(onSubmit, onError)}>---
ARIA Attributes
Required Attributes
<input
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
aria-required="true"
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email.message}
</span>
)}---
Custom Error Messages
Method 1: In Zod Schema
const schema = z.object({
email: z.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
password: z.string()
.min(8, { message: 'Password must be at least 8 characters long' }),
})Method 2: Custom Error Map
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
switch (issue.code) {
case z.ZodIssueCode.too_small:
return { message: `Must be at least ${issue.minimum} characters` }
case z.ZodIssueCode.invalid_string:
if (issue.validation === 'email') {
return { message: 'Please enter a valid email address' }
}
break
default:
return { message: ctx.defaultError }
}
}
z.setErrorMap(customErrorMap)---
Error Formatting
Flatten Errors for Forms
try {
schema.parse(data)
} catch (error) {
if (error instanceof z.ZodError) {
const formattedErrors = error.flatten().fieldErrors
// Result: { email: ['Invalid email'], password: ['Too short'] }
}
}Format Errors for Display
const formatError = (error: FieldError): string => {
switch (error.type) {
case 'required':
return 'This field is required'
case 'min':
return `Minimum length is ${error.message}`
case 'pattern':
return 'Invalid format'
default:
return error.message || 'Invalid value'
}
}---
Server Error Integration
const onSubmit = async (data) => {
try {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
})
const result = await response.json()
if (!result.success && result.errors) {
// Map server errors to form fields
Object.entries(result.errors).forEach(([field, message]) => {
setError(field, {
type: 'server',
message: Array.isArray(message) ? message[0] : message,
})
})
}
} catch (error) {
// Network error
setError('root', {
type: 'server',
message: 'Unable to connect. Please try again.',
})
}
}---
Error Persistence
Clear Errors on Input Change
<input
{...register('email')}
onChange={(e) => {
register('email').onChange(e)
clearErrors('email') // Clear error when user starts typing
}}
/>Clear All Errors on Submit Success
const onSubmit = async (data) => {
const success = await submitData(data)
if (success) {
reset() // Clears form and errors
}
}---
Internationalization (i18n)
import { useTranslation } from 'react-i18next'
const { t } = useTranslation()
const schema = z.object({
email: z.string().email(t('errors.invalidEmail')),
password: z.string().min(8, t('errors.passwordTooShort')),
})---
Error Components
Reusable Error Display
function FormError({ error }: { error?: FieldError }) {
if (!error) return null
return (
<div role="alert" className="error">
<svg className="icon">...</svg>
<span>{error.message}</span>
</div>
)
}
// Usage
<FormError error={errors.email} />Field Group with Error
function FieldGroup({ name, label, type = 'text', register, errors }) {
return (
<div className="field-group">
<label htmlFor={name}>{label}</label>
<input
id={name}
type={type}
{...register(name)}
aria-invalid={errors[name] ? 'true' : 'false'}
/>
{errors[name] && <FormError error={errors[name]} />}
</div>
)
}---
Official Docs: https://react-hook-form.com/
Links to Official Documentation
Organized links to official documentation and resources.
---
React Hook Form
Core Documentation
- Main Site: https://react-hook-form.com/
- Get Started: https://react-hook-form.com/get-started
- API Reference: https://react-hook-form.com/api
- TS Support: https://react-hook-form.com/ts
Hooks
- useForm: https://react-hook-form.com/api/useform
- useController: https://react-hook-form.com/api/usecontroller
- useFieldArray: https://react-hook-form.com/api/usefieldarray
- useWatch: https://react-hook-form.com/api/usewatch
- useFormContext: https://react-hook-form.com/api/useformcontext
- useFormState: https://react-hook-form.com/api/useformstate
- Controller: https://react-hook-form.com/api/controller
Advanced Usage
- Smart Form Component: https://react-hook-form.com/advanced-usage#SmartFormComponent
- Error Messages: https://react-hook-form.com/advanced-usage#ErrorMessages
- Accessibility: https://react-hook-form.com/advanced-usage#AccessibilityA11y
- Performance: https://react-hook-form.com/advanced-usage#PerformanceOptimization
- Schema Validation: https://react-hook-form.com/advanced-usage#SchemaValidation
Examples
- Examples Library: https://react-hook-form.com/form-builder
- CodeSandbox Examples: https://codesandbox.io/examples/package/react-hook-form
---
Zod
Core Documentation
- Main Site: https://zod.dev/
- Installation: https://zod.dev/#installation
- Basic Usage: https://zod.dev/basics
- Primitives: https://zod.dev/primitives
- Coercion: https://zod.dev/coercion
Schema Types
- Objects: https://zod.dev/objects
- Arrays: https://zod.dev/arrays
- Unions: https://zod.dev/unions
- Records: https://zod.dev/records
- Maps: https://zod.dev/maps
- Sets: https://zod.dev/sets
- Promises: https://zod.dev/promises
Validation
- Refinements: https://zod.dev/refinements
- Transforms: https://zod.dev/transforms
- Preprocessing: https://zod.dev/preprocessing
- Pipes: https://zod.dev/pipes
Error Handling
- Error Handling: https://zod.dev/error-handling
- Custom Error Messages: https://zod.dev/error-handling#custom-error-messages
- Error Formatting: https://zod.dev/error-handling#formatting
TypeScript
- Type Inference: https://zod.dev/type-inference
- Type Helpers: https://zod.dev/type-inference#type-helpers
---
@hookform/resolvers
Documentation
- Main Docs: https://github.com/react-hook-form/resolvers
- zodResolver: https://github.com/react-hook-form/resolvers#zod
- All Resolvers: https://github.com/react-hook-form/resolvers#api
Installation
npm install @hookform/resolvers---
shadcn/ui
Form Components
- Form Component: https://ui.shadcn.com/docs/components/form
- Input: https://ui.shadcn.com/docs/components/input
- Textarea: https://ui.shadcn.com/docs/components/textarea
- Select: https://ui.shadcn.com/docs/components/select
- Checkbox: https://ui.shadcn.com/docs/components/checkbox
- Radio Group: https://ui.shadcn.com/docs/components/radio-group
- Switch: https://ui.shadcn.com/docs/components/switch
- Button: https://ui.shadcn.com/docs/components/button
Installation
- Vite Setup: https://ui.shadcn.com/docs/installation/vite
- Next.js Setup: https://ui.shadcn.com/docs/installation/next
- CLI: https://ui.shadcn.com/docs/cli
---
TypeScript
Documentation
- Handbook: https://www.typescriptlang.org/docs/handbook/intro.html
- Type Inference: https://www.typescriptlang.org/docs/handbook/type-inference.html
- Generics: https://www.typescriptlang.org/docs/handbook/2/generics.html
---
Accessibility (WCAG)
Guidelines
- WCAG 2.1: https://www.w3.org/WAI/WCAG21/quickref/
- ARIA Authoring Practices: https://www.w3.org/WAI/ARIA/apg/
- Forms Best Practices: https://www.w3.org/WAI/tutorials/forms/
---
Community Resources
React Hook Form
- GitHub: https://github.com/react-hook-form/react-hook-form
- Discord: https://discord.gg/yYv7GZ8
- Stack Overflow: https://stackoverflow.com/questions/tagged/react-hook-form
Zod
- GitHub: https://github.com/colinhacks/zod
- Discord: https://discord.gg/RcG33DQJdf
- Stack Overflow: https://stackoverflow.com/questions/tagged/zod
---
Video Tutorials
React Hook Form
- Official YouTube: https://www.youtube.com/@bluebill1049
- Traversy Media: https://www.youtube.com/watch?v=bU_eq8qyjic
- Web Dev Simplified: https://www.youtube.com/watch?v=cc_xmawJ8Kg
Zod
- Matt Pocock: https://www.youtube.com/watch?v=L6BE-U3oy80
- Theo: https://www.youtube.com/watch?v=AeQ3f4zmSMs
---
Blog Posts & Articles
React Hook Form
- React Hook Form Best Practices: https://react-hook-form.com/faqs
- Performance Comparison: https://react-hook-form.com/faqs#PerformanceofReactHookForm
Zod
- Total TypeScript: https://www.totaltypescript.com/tutorials/zod
- Zod Tutorial: https://zod.dev/tutorials
---
Package Managers
npm
npm install react-hook-form zod @hookform/resolverspnpm
pnpm add react-hook-form zod @hookform/resolversyarn
yarn add react-hook-form zod @hookform/resolvers---
Version Information
Latest Tested Versions (as of 2025-10-23):
- react-hook-form: 7.65.0
- zod: 4.1.12
- @hookform/resolvers: 5.2.2
Check for updates:
npm view react-hook-form version
npm view zod version
npm view @hookform/resolvers version---
Last Updated: 2025-10-23
Performance Optimization Guide
Strategies for optimizing React Hook Form performance.
---
Form Validation Modes
onSubmit (Best Performance)
const form = useForm({
mode: 'onSubmit', // Validate only on submit
resolver: zodResolver(schema),
})Pros: Minimal re-renders, best performance Cons: No live feedback
onBlur (Good Balance)
const form = useForm({
mode: 'onBlur', // Validate when field loses focus
resolver: zodResolver(schema),
})Pros: Good UX, reasonable performance Cons: Some re-renders on blur
onChange (Live Feedback)
const form = useForm({
mode: 'onChange', // Validate on every change
resolver: zodResolver(schema),
})Pros: Immediate feedback Cons: Most re-renders, can be slow with complex validation
all (Maximum Validation)
const form = useForm({
mode: 'all', // Validate on blur, change, and submit
resolver: zodResolver(schema),
})Pros: Most responsive Cons: Highest performance cost
---
Controlled vs Uncontrolled
Uncontrolled (Faster)
// Best performance - no React state
<input {...register('email')} />Controlled (More Control)
// More React state = more re-renders
<Controller
control={control}
name="email"
render={({ field }) => <Input {...field} />}
/>Rule: Use register by default, Controller only when necessary.
---
watch() Optimization
Watch Specific Fields
// BAD - Watches all fields, re-renders on any change
const values = watch()
// GOOD - Watch only what you need
const email = watch('email')
const [email, password] = watch(['email', 'password'])useWatch for Isolation
import { useWatch } from 'react-hook-form'
// Isolated component - only re-renders when email changes
function EmailDisplay() {
const email = useWatch({ control, name: 'email' })
return <div>{email}</div>
}---
Debouncing Validation
Manual Debounce
import { useDebouncedCallback } from 'use-debounce'
const debouncedValidation = useDebouncedCallback(
() => trigger('username'),
500 // Wait 500ms
)
<input
{...register('username')}
onChange={(e) => {
register('username').onChange(e)
debouncedValidation()
}}
/>---
shouldUnregister Flag
Keep Data When Unmounting
const form = useForm({
shouldUnregister: false, // Keep field data when unmounted
})Use When:
- Multi-step forms
- Tabbed interfaces
- Conditional fields that should persist
Clear Data When Unmounting
const form = useForm({
shouldUnregister: true, // Remove field data when unmounted
})Use When:
- Truly conditional fields
- Dynamic forms
- Want to clear data automatically
---
useFieldArray Optimization
Use field.id as Key
// CRITICAL for performance
{fields.map((field) => (
<div key={field.id}> {/* Not index! */}
...
</div>
))}Avoid Unnecessary Re-renders
// Extract field components
const FieldItem = React.memo(({ field, index, register, remove }) => (
<div>
<input {...register(`items.${index}.name`)} />
<button onClick={() => remove(index)}>Remove</button>
</div>
))
// Use memoized component
{fields.map((field, index) => (
<FieldItem
key={field.id}
field={field}
index={index}
register={register}
remove={remove}
/>
))}---
formState Optimization
Subscribe to Specific Properties
// BAD - Subscribes to all formState changes
const { formState } = useForm()
// GOOD - Subscribe only to what you need
const { isDirty, isValid } = useForm().formState
// BETTER - Use useFormState for isolation
import { useFormState } from 'react-hook-form'
const { isDirty } = useFormState({ control })---
Resolver Optimization
Memoize Schema
// BAD - New schema on every render
const form = useForm({
resolver: zodResolver(z.object({ email: z.string() })),
})
// GOOD - Schema defined outside component
const schema = z.object({ email: z.string() })
function Form() {
const form = useForm({
resolver: zodResolver(schema),
})
}---
Large Forms
Split into Sections
function PersonalInfoSection() {
const { register } = useFormContext()
return (
<div>
<input {...register('firstName')} />
<input {...register('lastName')} />
</div>
)
}
function ContactInfoSection() {
const { register } = useFormContext()
return (
<div>
<input {...register('email')} />
<input {...register('phone')} />
</div>
)
}
function LargeForm() {
const methods = useForm()
return (
<FormProvider {...methods}>
<form>
<PersonalInfoSection />
<ContactInfoSection />
</form>
</FormProvider>
)
}Virtualize Long Lists
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualizedFieldArray() {
const { fields } = useFieldArray({ control, name: 'items' })
const parentRef = React.useRef(null)
const rowVirtualizer = useVirtualizer({
count: fields.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
})
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const field = fields[virtualRow.index]
return (
<div key={field.id}>
<input {...register(`items.${virtualRow.index}.name`)} />
</div>
)
})}
</div>
</div>
)
}---
Performance Benchmarks
| Optimization | Before | After | Improvement |
|---|---|---|---|
| mode: onSubmit vs onChange | 100ms | 20ms | 80% |
| watch() all vs watch('field') | 50ms | 10ms | 80% |
| field.id vs index key | 200ms | 50ms | 75% |
| Memoized schema | 30ms | 5ms | 83% |
---
Profiling
React DevTools Profiler
1. Open React DevTools 2. Go to Profiler tab 3. Click Record 4. Interact with form 5. Stop recording 6. Analyze render times
Performance.mark API
const onSubmit = (data) => {
performance.mark('form-submit-start')
// Submit logic
performance.mark('form-submit-end')
performance.measure('form-submit', 'form-submit-start', 'form-submit-end')
const measures = performance.getEntriesByName('form-submit')
console.log('Submit time:', measures[0].duration, 'ms')
}---
Official Docs: https://react-hook-form.com/advanced-usage#PerformanceOptimization
React Hook Form API Reference
Complete API reference for React Hook Form v7.65.0
---
useForm Hook
const {
register,
handleSubmit,
watch,
formState,
setValue,
getValues,
reset,
trigger,
control,
setError,
clearErrors,
setFocus,
} = useForm<FormData>(options)Options
| Option | Type | Description |
|---|---|---|
resolver | Resolver | Schema validation resolver (zodResolver, etc.) |
mode | `'onSubmit' \ | 'onChange' \ |
reValidateMode | `'onChange' \ | 'onBlur'` |
defaultValues | `object \ | () => object \ |
values | object | Controlled form values |
resetOptions | object | Options for reset behavior |
shouldUnregister | boolean | Unregister fields when unmounted |
shouldFocusError | boolean | Focus first error on submit |
criteriaMode | `'firstError' \ | 'all'` |
delayError | number | Delay error display (ms) |
---
register
Register input and apply validation rules.
<input {...register('fieldName', options)} />Options:
required:boolean | stringmin:number | { value: number, message: string }max:number | { value: number, message: string }minLength:number | { value: number, message: string }maxLength:number | { value: number, message: string }pattern:RegExp | { value: RegExp, message: string }validate:(value) => boolean | string | objectvalueAsNumber:booleanvalueAsDate:booleandisabled:booleanonChange:(e) => voidonBlur:(e) => void
---
handleSubmit
Wraps your form submission handler.
<form onSubmit={handleSubmit(onSubmit, onError)}>
function onSubmit(data: FormData) {
// Valid data
}
function onError(errors: FieldErrors) {
// Validation errors
}---
watch
Watch specified inputs and return their values.
// Watch all fields
const values = watch()
// Watch specific field
const email = watch('email')
// Watch multiple fields
const [email, password] = watch(['email', 'password'])
// Watch with callback
useEffect(() => {
const subscription = watch((value, { name, type }) => {
console.log(value, name, type)
})
return () => subscription.unsubscribe()
}, [watch])---
formState
Form state object.
const {
isDirty, // Form has been modified
dirtyFields, // Object of modified fields
touchedFields, // Object of touched fields
isSubmitted, // Form has been submitted
isSubmitSuccessful, // Last submission successful
isSubmitting, // Form is currently submitting
isValidating, // Form is validating
isValid, // Form is valid
errors, // Validation errors
submitCount, // Number of submissions
} = formState---
setValue
Set field value programmatically.
setValue('fieldName', value, options)
// Options
{
shouldValidate: boolean, // Trigger validation
shouldDirty: boolean, // Mark as dirty
shouldTouch: boolean, // Mark as touched
}---
getValues
Get current form values.
// Get all values
const values = getValues()
// Get specific field
const email = getValues('email')
// Get multiple fields
const [email, password] = getValues(['email', 'password'])---
reset
Reset form to default values.
reset() // Reset to defaultValues
reset({ email: '', password: '' }) // Reset to specific values
reset(undefined, {
keepErrors: boolean,
keepDirty: boolean,
keepIsSubmitted: boolean,
keepTouched: boolean,
keepIsValid: boolean,
keepSubmitCount: boolean,
})---
trigger
Manually trigger validation.
// Trigger all fields
await trigger()
// Trigger specific field
await trigger('email')
// Trigger multiple fields
await trigger(['email', 'password'])---
setError
Set field error manually.
setError('fieldName', {
type: 'manual',
message: 'Error message',
})
// Root error (not tied to specific field)
setError('root', {
type: 'server',
message: 'Server error',
})---
clearErrors
Clear field errors.
clearErrors() // Clear all errors
clearErrors('email') // Clear specific field
clearErrors(['email', 'password']) // Clear multiple fields---
setFocus
Focus on specific field.
setFocus('fieldName', { shouldSelect: true })---
Controller
For controlled components (third-party UI libraries).
import { Controller } from 'react-hook-form'
<Controller
name="fieldName"
control={control}
defaultValue=""
rules={{ required: true }}
render={({ field, fieldState, formState }) => (
<CustomInput
{...field}
error={fieldState.error}
/>
)}
/>render props:
field:{ value, onChange, onBlur, ref, name }fieldState:{ invalid, isTouched, isDirty, error }formState: Full form state
---
useController
Hook version of Controller (for reusable components).
import { useController } from 'react-hook-form'
function CustomInput({ name, control }) {
const {
field,
fieldState: { invalid, isTouched, isDirty, error },
formState: { touchedFields, dirtyFields }
} = useController({
name,
control,
rules: { required: true },
defaultValue: '',
})
return <input {...field} />
}---
useFieldArray
Manage dynamic field arrays.
import { useFieldArray } from 'react-hook-form'
const { fields, append, prepend, remove, insert, update, replace } = useFieldArray({
control,
name: 'items',
keyName: 'id', // Default: 'id'
})Methods:
append(value)- Add to endprepend(value)- Add to beginninginsert(index, value)- Insert at indexremove(index)- Remove at indexupdate(index, value)- Update at indexreplace(values)- Replace entire array
Important: Use field.id as key, not array index!
{fields.map((field, index) => (
<div key={field.id}> {/* Use field.id! */}
<input {...register(`items.${index}.name`)} />
</div>
))}---
useWatch
Subscribe to input changes without re-rendering entire form.
import { useWatch } from 'react-hook-form'
const email = useWatch({
control,
name: 'email',
defaultValue: '',
})---
useFormState
Subscribe to form state without re-rendering entire form.
import { useFormState } from 'react-hook-form'
const { isDirty, isValid } = useFormState({ control })---
useFormContext
Access form context (for deeply nested components).
import { useFormContext } from 'react-hook-form'
function NestedComponent() {
const { register, formState: { errors } } = useFormContext()
return <input {...register('email')} />
}
// Wrap form with FormProvider
import { FormProvider, useForm } from 'react-hook-form'
function App() {
const methods = useForm()
return (
<FormProvider {...methods}>
<form>
<NestedComponent />
</form>
</FormProvider>
)
}---
ErrorMessage
Helper component for displaying errors (from @hookform/error-message).
import { ErrorMessage } from '@hookform/error-message'
<ErrorMessage
errors={errors}
name="email"
render={({ message }) => <span className="error">{message}</span>}
/>---
DevTool
Development tool for debugging (from @hookform/devtools).
import { DevTool } from '@hookform/devtools'
<DevTool control={control} />---
Official Docs: https://react-hook-form.com/
shadcn/ui Integration Guide
Complete guide for using shadcn/ui with React Hook Form + Zod.
---
Form Component (Legacy)
Status: "Not actively developed" according to shadcn/ui documentation Recommendation: Use Field component for new projects (coming soon)
Installation
npx shadcn@latest add formBasic Usage
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'
const schema = z.object({
username: z.string().min(2),
})
function ProfileForm() {
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { username: '' },
})
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
}---
Form Component Anatomy
FormField
<FormField
control={form.control} // Required
name="fieldName" // Required
render={({ field, fieldState, formState }) => (
// Your field component
)}
/>FormItem
Container for field, label, description, and message.
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Helper text</FormDescription>
<FormMessage />
</FormItem>FormControl
Wraps the actual input component.
<FormControl>
<Input {...field} />
</FormControl>FormLabel
Accessible label with automatic linking to input.
<FormLabel>Email Address</FormLabel>FormDescription
Helper text for the field.
<FormDescription>
We'll never share your email.
</FormDescription>FormMessage
Displays validation errors.
<FormMessage />---
Common Patterns
Input Field
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>Textarea
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>Select
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>Checkbox
<FormField
control={form.control}
name="newsletter"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Subscribe to newsletter</FormLabel>
<FormDescription>
Receive email updates about new products.
</FormDescription>
</div>
</FormItem>
)}
/>Radio Group
<FormField
control={form.control}
name="plan"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>Select a plan</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="free" />
</FormControl>
<FormLabel className="font-normal">Free</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="pro" />
</FormControl>
<FormLabel className="font-normal">Pro</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>Switch
<FormField
control={form.control}
name="notifications"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Email Notifications
</FormLabel>
<FormDescription>
Receive emails about your account activity.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>---
Nested Objects
const schema = z.object({
user: z.object({
name: z.string(),
email: z.string().email(),
}),
})
<FormField
control={form.control}
name="user.name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>---
Arrays
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'items',
})
{fields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`items.${index}.name`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}---
Custom Validation
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
{/* Custom error styling */}
{errors.username && (
<div className="text-sm font-medium text-destructive">
{errors.username.message}
</div>
)}
</FormItem>
)}
/>---
Field Component (Future)
Status: Recommended for new implementations (in development)
Check official docs for latest: https://ui.shadcn.com/docs/components/form
---
Tips
1. Always spread {...field} in FormControl 2. Use Form component for automatic ID generation 3. FormMessage automatically displays errors 4. Combine with Zod for type-safe validation 5. Check documentation - Form component is not actively developed
---
Official Docs:
- shadcn/ui Form: https://ui.shadcn.com/docs/components/form
- React Hook Form: https://react-hook-form.com/
Top 12 Common Errors with Solutions
Complete reference for known issues and their solutions.
---
1. Zod v4 Type Inference Errors
Error: Type inference doesn't work correctly with Zod v4
Symptoms:
// Types don't match expected structure
const schema = z.object({ name: z.string() })
type FormData = z.infer<typeof schema> // Type issuesSource: GitHub Issue #13109 (Closed 2025-11-01)
Note: This issue was resolved in react-hook-form v7.66.x. Upgrade to v7.66.1+ to avoid this problem.
Solution:
// Use correct Zod v4 patterns
const schema = z.object({ name: z.string() })
type FormData = z.infer<typeof schema>
// Explicitly type useForm if needed
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
})---
2. Uncontrolled to Controlled Warning
Error: "A component is changing an uncontrolled input to be controlled"
Symptoms:
Warning: A component is changing an uncontrolled input of type text to be controlled.
Input elements should not switch from uncontrolled to controlled (or vice versa).Cause: Not setting defaultValues causes fields to be undefined initially
Solution:
// BAD
const form = useForm()
// GOOD - Always set defaultValues
const form = useForm({
defaultValues: {
email: '',
password: '',
remember: false,
},
})---
3. Nested Object Validation Errors
Error: Errors for nested fields don't display correctly
Symptoms:
// errors.address.street is undefined even though validation failed
<span>{errors.address.street?.message}</span> // Shows nothingSolution:
// Use optional chaining for nested errors
{errors.address?.street && (
<span>{errors.address.street.message}</span>
)}
// OR check if errors.address exists first
{errors.address && errors.address.street && (
<span>{errors.address.street.message}</span>
)}---
4. Array Field Re-renders
Error: Form re-renders excessively with useFieldArray
Cause: Using array index as key instead of field.id
Solution:
// BAD
{fields.map((field, index) => (
<div key={index}> {/* Using index causes re-renders */}
...
</div>
))}
// GOOD
{fields.map((field) => (
<div key={field.id}> {/* Use field.id */}
...
</div>
))}---
5. Async Validation Race Conditions
Error: Multiple validation requests cause conflicting results
Symptoms: Old validation results override new ones
Solution:
// Use debouncing
import { useDebouncedCallback } from 'use-debounce'
const debouncedValidation = useDebouncedCallback(
() => trigger('username'),
500 // Wait 500ms after user stops typing
)
// AND cancel pending requests
const abortControllerRef = useRef<AbortController | null>(null)
useEffect(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
abortControllerRef.current = new AbortController()
// Make request with abort signal
fetch('/api/check', { signal: abortControllerRef.current.signal })
}, [value])---
6. Server Error Mapping
Error: Server validation errors don't map to form fields
Solution:
const onSubmit = async (data) => {
try {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
})
if (!response.ok) {
const { errors } = await response.json()
// Map server errors to form fields
Object.entries(errors).forEach(([field, message]) => {
setError(field, {
type: 'server',
message: Array.isArray(message) ? message[0] : message,
})
})
return
}
} catch (error) {
setError('root', {
type: 'server',
message: 'Network error',
})
}
}---
7. Default Values Not Applied
Error: Form fields don't show default values
Cause: Setting defaultValues after form initialization
Solution:
// BAD - Set in useState
const [defaultValues, setDefaultValues] = useState({})
useEffect(() => {
setDefaultValues({ email: 'user@example.com' }) // Too late!
}, [])
const form = useForm({ defaultValues })
// GOOD - Set directly or use reset()
const form = useForm({
defaultValues: { email: 'user@example.com' },
})
// OR fetch and use reset
useEffect(() => {
async function loadData() {
const data = await fetchData()
reset(data)
}
loadData()
}, [reset])---
8. Controller Field Not Updating
Error: Custom component doesn't update when value changes
Cause: Not spreading {...field} in Controller render
Solution:
// BAD
<Controller
render={({ field }) => (
<CustomInput value={field.value} onChange={field.onChange} />
)}
/>
// GOOD - Spread all field props
<Controller
render={({ field }) => (
<CustomInput {...field} />
)}
/>---
9. useFieldArray Key Warnings
Error: React warning about duplicate keys in list
Symptoms:
Warning: Encountered two children with the same keySolution:
// BAD - Using index as key
{fields.map((field, index) => (
<div key={index}>...</div>
))}
// GOOD - Use field.id
{fields.map((field) => (
<div key={field.id}>...</div>
))}---
10. Schema Refinement Error Paths
Error: Custom validation errors appear at wrong field
Cause: Not specifying path in refinement
Solution:
// BAD - Error appears at form level
z.object({
password: z.string(),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
// Missing path!
})
// GOOD - Error appears at confirmPassword field
z.object({
password: z.string(),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'], // Specify path
})---
11. Transform vs Preprocess Confusion
Error: Data transformation doesn't work as expected
When to use each:
// Use TRANSFORM for output transformation (after validation)
z.string().transform((val) => val.toUpperCase())
// Input: 'hello' -> Validation: passes -> Output: 'HELLO'
// Use PREPROCESS for input transformation (before validation)
z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().optional()
)
// Input: '' -> Preprocess: undefined -> Validation: passes---
12. Multiple Resolver Conflicts
Error: Form validation doesn't work with multiple resolvers
Cause: Trying to use multiple validation libraries simultaneously
Solution:
// BAD - Can't use multiple resolvers
const form = useForm({
resolver: zodResolver(schema),
resolver: yupResolver(schema), // Overrides previous
})
// GOOD - Use single resolver, combine schemas if needed
const schema1 = z.object({ email: z.string() })
const schema2 = z.object({ password: z.string() })
const combinedSchema = schema1.merge(schema2)
const form = useForm({
resolver: zodResolver(combinedSchema),
})---
Debugging Tips
Enable DevTools
npm install @hookform/devtoolsimport { DevTool } from '@hookform/devtools'
<DevTool control={control} />Log Form State
useEffect(() => {
console.log('Form State:', formState)
console.log('Errors:', errors)
console.log('Values:', getValues())
}, [formState, errors, getValues])Validate on Change During Development
const form = useForm({
mode: 'onChange', // See errors immediately
resolver: zodResolver(schema),
})---
Official Docs:
- React Hook Form: https://react-hook-form.com/
- Zod: https://zod.dev/
Comprehensive Zod Schemas Guide
Complete reference for all Zod schema types and patterns.
---
Primitives
// String
z.string()
z.string().min(3, "Min 3 characters")
z.string().max(100, "Max 100 characters")
z.string().length(10, "Exactly 10 characters")
z.string().email("Invalid email")
z.string().url("Invalid URL")
z.string().uuid("Invalid UUID")
z.string().regex(/pattern/, "Does not match pattern")
z.string().trim() // Trim whitespace
z.string().toLowerCase() //Convert to lowercase
z.string().toUpperCase() // Convert to uppercase
// Number
z.number()
z.number().int("Must be integer")
z.number().positive("Must be positive")
z.number().negative("Must be negative")
z.number().min(0, "Min is 0")
z.number().max(100, "Max is 100")
z.number().multipleOf(5, "Must be multiple of 5")
z.number().finite() // No Infinity or NaN
z.number().safe() // Within JS safe integer range
// Boolean
z.boolean()
// Date
z.date()
z.date().min(new Date("2020-01-01"), "Too old")
z.date().max(new Date(), "Cannot be in future")
// BigInt
z.bigint()---
Objects
// Basic object
const userSchema = z.object({
name: z.string(),
age: z.number(),
})
// Nested object
const profileSchema = z.object({
user: userSchema,
address: z.object({
street: z.string(),
city: z.string(),
}),
})
// Partial (all fields optional)
const partialUserSchema = userSchema.partial()
// Deep Partial (recursively optional)
const deepPartialSchema = profileSchema.deepPartial()
// Pick specific fields
const nameOnlySchema = userSchema.pick({ name: true })
// Omit specific fields
const withoutAgeSchema = userSchema.omit({ age: true })
// Merge objects
const extendedUserSchema = userSchema.merge(z.object({
email: z.string().email(),
}))
// Passthrough (allow extra fields)
const passthroughSchema = userSchema.passthrough()
// Strict (no extra fields)
const strictSchema = userSchema.strict()
// Catchall (type for extra fields)
const catchallSchema = userSchema.catchall(z.string())---
Arrays
// Array of strings
z.array(z.string())
// With length constraints
z.array(z.string()).min(1, "At least one item required")
z.array(z.string()).max(10, "Max 10 items")
z.array(z.string()).length(5, "Exactly 5 items")
z.array(z.string()).nonempty("Array cannot be empty")
// Array of objects
z.array(z.object({
name: z.string(),
age: z.number(),
}))---
Tuples
// Fixed-length array with specific types
z.tuple([z.string(), z.number(), z.boolean()])
// With rest
z.tuple([z.string(), z.number()]).rest(z.boolean())---
Enums and Literals
// Enum
z.enum(['red', 'green', 'blue'])
// Native enum
enum Color { Red, Green, Blue }
z.nativeEnum(Color)
// Literal
z.literal('hello')
z.literal(42)
z.literal(true)---
Unions and Discriminated Unions
// Union
z.union([z.string(), z.number()])
// Discriminated union (recommended for better errors)
z.discriminatedUnion('type', [
z.object({ type: z.literal('user'), name: z.string() }),
z.object({ type: z.literal('admin'), permissions: z.array(z.string()) }),
])---
Optional and Nullable
// Optional (value | undefined)
z.string().optional()
z.optional(z.string()) // Same as above
// Nullable (value | null)
z.string().nullable()
z.nullable(z.string()) // Same as above
// Nullish (value | null | undefined)
z.string().nullish()---
Default Values
z.string().default('default value')
z.number().default(0)
z.boolean().default(false)
z.array(z.string()).default([])---
Refinements (Custom Validation)
// Basic refinement
z.string().refine((val) => val.length > 5, {
message: "String must be longer than 5 characters",
})
// With custom path
z.object({
password: z.string(),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
})
// Multiple refinements
z.string()
.refine((val) => val.length >= 8, "Min 8 characters")
.refine((val) => /[A-Z]/.test(val), "Must contain uppercase")
.refine((val) => /[0-9]/.test(val), "Must contain number")
// Async refinement
z.string().refine(async (val) => {
const available = await checkAvailability(val)
return available
}, "Already taken")---
Transforms
// String to number
z.string().transform((val) => parseInt(val, 10))
// Trim whitespace
z.string().transform((val) => val.trim())
// Parse date
z.string().transform((val) => new Date(val))
// Chain transform and refine
z.string()
.transform((val) => parseInt(val, 10))
.refine((val) => !isNaN(val), "Must be a number")---
Preprocess
// Process before validation
z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().optional()
)
// Convert to number
z.preprocess(
(val) => Number(val),
z.number()
)---
Intersections
const baseUser = z.object({ name: z.string() })
const withEmail = z.object({ email: z.string().email() })
// Intersection (combines both)
const userWithEmail = baseUser.and(withEmail)
// OR
const userWithEmail = z.intersection(baseUser, withEmail)---
Records and Maps
// Record (object with dynamic keys)
z.record(z.string()) // { [key: string]: string }
z.record(z.string(), z.number()) // { [key: string]: number }
// Map
z.map(z.string(), z.number())---
Sets
z.set(z.string())
z.set(z.number()).min(1, "At least one item")
z.set(z.string()).max(10, "Max 10 items")---
Promises
z.promise(z.string())
z.promise(z.object({ data: z.string() }))---
Custom Error Messages
// Field-level
z.string({ required_error: "Name is required" })
z.number({ invalid_type_error: "Must be a number" })
// Validation-level
z.string().min(3, { message: "Min 3 characters" })
z.string().email({ message: "Invalid email format" })
// Custom error map
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === "string") {
return { message: "Please enter text" }
}
}
return { message: ctx.defaultError }
}
z.setErrorMap(customErrorMap)---
Type Inference
const userSchema = z.object({
name: z.string(),
age: z.number(),
})
// Infer TypeScript type
type User = z.infer<typeof userSchema>
// Result: { name: string; age: number }
// Input type (before transforms)
type UserInput = z.input<typeof transformSchema>
// Output type (after transforms)
type UserOutput = z.output<typeof transformSchema>---
Parsing Methods
// .parse() - throws on error
const result = schema.parse(data)
// .safeParse() - returns result object
const result = schema.safeParse(data)
if (result.success) {
console.log(result.data)
} else {
console.error(result.error)
}
// .parseAsync() - async validation
const result = await schema.parseAsync(data)
// .safeParseAsync() - async with result object
const result = await schema.safeParseAsync(data)---
Error Handling
try {
schema.parse(data)
} catch (error) {
if (error instanceof z.ZodError) {
// Formatted errors
console.log(error.format())
// Flattened errors (for forms)
console.log(error.flatten())
// Individual issues
console.log(error.issues)
}
}---
Official Docs: https://zod.dev
#!/bin/bash
# Check Latest Versions of React Hook Form + Zod Packages
# Usage: ./check-versions.sh
echo "Checking latest package versions..."
echo ""
# Color codes
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check react-hook-form
echo -e "${GREEN}react-hook-form:${NC}"
npm view react-hook-form version
echo ""
# Check zod
echo -e "${GREEN}zod:${NC}"
npm view zod version
echo ""
# Check @hookform/resolvers
echo -e "${GREEN}@hookform/resolvers:${NC}"
npm view @hookform/resolvers version
echo ""
# Check last 5 versions of each
echo "---"
echo ""
echo -e "${YELLOW}Last 5 versions of react-hook-form:${NC}"
npm view react-hook-form versions --json | tail -7 | head -6
echo ""
echo -e "${YELLOW}Last 5 versions of zod:${NC}"
npm view zod versions --json | tail -7 | head -6
echo ""
echo -e "${YELLOW}Last 5 versions of @hookform/resolvers:${NC}"
npm view @hookform/resolvers versions --json | tail -7 | head -6
echo ""
echo "---"
echo ""
echo "Documentation Tested Versions (as of 2025-10-23):"
echo " react-hook-form: 7.65.0"
echo " zod: 4.1.12"
echo " @hookform/resolvers: 5.2.2"
echo ""
echo "Run 'npm view <package> version' to check latest"
/**
* Advanced Form Example - User Profile with Nested Objects and Arrays
*
* Demonstrates:
* - Nested object validation (address)
* - Array field validation (skills)
* - Conditional field validation
* - Complex Zod schemas with refinements
* - Type-safe nested error handling
*/
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// Define nested schemas
const addressSchema = z.object({
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
state: z.string().min(2, 'State must be at least 2 characters'),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code format'),
country: z.string().min(1, 'Country is required'),
})
// Complex schema with nested objects and arrays
const profileSchema = z.object({
// Basic fields
firstName: z.string().min(2, 'First name must be at least 2 characters'),
lastName: z.string().min(2, 'Last name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number').optional(),
// Nested object
address: addressSchema,
// Array of strings
skills: z.array(z.string().min(1, 'Skill cannot be empty'))
.min(1, 'At least one skill is required')
.max(10, 'Maximum 10 skills allowed'),
// Conditional fields
isStudent: z.boolean(),
school: z.string().optional(),
graduationYear: z.number().int().min(1900).max(2100).optional(),
// Enum
experience: z.enum(['junior', 'mid', 'senior', 'lead'], {
errorMap: () => ({ message: 'Please select experience level' }),
}),
// Number with constraints
yearsOfExperience: z.number()
.int('Must be a whole number')
.min(0, 'Cannot be negative')
.max(50, 'Must be 50 or less'),
// Date
availableFrom: z.date().optional(),
// Boolean
agreedToTerms: z.boolean().refine((val) => val === true, {
message: 'You must agree to the terms and conditions',
}),
})
.refine((data) => {
// Conditional validation: if isStudent is true, school is required
if (data.isStudent && !data.school) {
return false
}
return true
}, {
message: 'School is required for students',
path: ['school'],
})
.refine((data) => {
// Experience level should match years of experience
if (data.experience === 'senior' && data.yearsOfExperience < 5) {
return false
}
return true
}, {
message: 'Senior level requires at least 5 years of experience',
path: ['yearsOfExperience'],
})
type ProfileFormData = z.infer<typeof profileSchema>
export function AdvancedProfileForm() {
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
setValue,
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
firstName: '',
lastName: '',
email: '',
phone: '',
address: {
street: '',
city: '',
state: '',
zipCode: '',
country: 'USA',
},
skills: [''], // Start with one empty skill
isStudent: false,
school: '',
experience: 'junior',
yearsOfExperience: 0,
agreedToTerms: false,
},
})
// Watch isStudent to conditionally show school field
const isStudent = watch('isStudent')
const onSubmit = async (data: ProfileFormData) => {
console.log('Profile data:', data)
// API call
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-2xl mx-auto">
<h2 className="text-3xl font-bold">User Profile</h2>
{/* Basic Information */}
<section className="space-y-4">
<h3 className="text-xl font-semibold">Basic Information</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium mb-1">
First Name *
</label>
<input
id="firstName"
{...register('firstName')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.firstName && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.firstName.message}
</span>
)}
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium mb-1">
Last Name *
</label>
<input
id="lastName"
{...register('lastName')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.lastName && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.lastName.message}
</span>
)}
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email *
</label>
<input
id="email"
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.email && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.email.message}
</span>
)}
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium mb-1">
Phone (Optional)
</label>
<input
id="phone"
type="tel"
{...register('phone')}
placeholder="+1234567890"
className="w-full px-3 py-2 border rounded-md"
/>
{errors.phone && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.phone.message}
</span>
)}
</div>
</section>
{/* Address (Nested Object) */}
<section className="space-y-4">
<h3 className="text-xl font-semibold">Address</h3>
<div>
<label htmlFor="street" className="block text-sm font-medium mb-1">
Street *
</label>
<input
id="street"
{...register('address.street')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.address?.street && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.address.street.message}
</span>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="city" className="block text-sm font-medium mb-1">
City *
</label>
<input
id="city"
{...register('address.city')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.address?.city && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.address.city.message}
</span>
)}
</div>
<div>
<label htmlFor="state" className="block text-sm font-medium mb-1">
State *
</label>
<input
id="state"
{...register('address.state')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.address?.state && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.address.state.message}
</span>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="zipCode" className="block text-sm font-medium mb-1">
ZIP Code *
</label>
<input
id="zipCode"
{...register('address.zipCode')}
placeholder="12345 or 12345-6789"
className="w-full px-3 py-2 border rounded-md"
/>
{errors.address?.zipCode && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.address.zipCode.message}
</span>
)}
</div>
<div>
<label htmlFor="country" className="block text-sm font-medium mb-1">
Country *
</label>
<input
id="country"
{...register('address.country')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.address?.country && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.address.country.message}
</span>
)}
</div>
</div>
</section>
{/* Skills (Array - simplified for advanced-form, see dynamic-fields.tsx for full array handling) */}
<section className="space-y-4">
<h3 className="text-xl font-semibold">Skills</h3>
<p className="text-sm text-gray-600">
Enter skills separated by commas (handled as string for simplicity in this example)
</p>
<div>
<label htmlFor="skills" className="block text-sm font-medium mb-1">
Skills (comma-separated) *
</label>
<input
id="skills"
{...register('skills.0')} // Simplified - see dynamic-fields.tsx for proper array handling
placeholder="React, TypeScript, Node.js"
className="w-full px-3 py-2 border rounded-md"
/>
{errors.skills && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.skills.message || errors.skills[0]?.message}
</span>
)}
</div>
</section>
{/* Experience */}
<section className="space-y-4">
<h3 className="text-xl font-semibold">Experience</h3>
<div>
<label htmlFor="experience" className="block text-sm font-medium mb-1">
Experience Level *
</label>
<select
id="experience"
{...register('experience')}
className="w-full px-3 py-2 border rounded-md"
>
<option value="junior">Junior</option>
<option value="mid">Mid-Level</option>
<option value="senior">Senior</option>
<option value="lead">Lead</option>
</select>
{errors.experience && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.experience.message}
</span>
)}
</div>
<div>
<label htmlFor="yearsOfExperience" className="block text-sm font-medium mb-1">
Years of Experience *
</label>
<input
id="yearsOfExperience"
type="number"
{...register('yearsOfExperience', { valueAsNumber: true })}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.yearsOfExperience && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.yearsOfExperience.message}
</span>
)}
</div>
</section>
{/* Conditional Fields */}
<section className="space-y-4">
<h3 className="text-xl font-semibold">Education</h3>
<div className="flex items-center">
<input
id="isStudent"
type="checkbox"
{...register('isStudent')}
className="h-4 w-4 rounded"
/>
<label htmlFor="isStudent" className="ml-2 text-sm">
I am currently a student
</label>
</div>
{/* Conditional field - only show if isStudent is true */}
{isStudent && (
<div>
<label htmlFor="school" className="block text-sm font-medium mb-1">
School Name *
</label>
<input
id="school"
{...register('school')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.school && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.school.message}
</span>
)}
</div>
)}
</section>
{/* Terms and Conditions */}
<section className="space-y-4">
<div className="flex items-start">
<input
id="agreedToTerms"
type="checkbox"
{...register('agreedToTerms')}
className="h-4 w-4 rounded mt-1"
/>
<label htmlFor="agreedToTerms" className="ml-2 text-sm">
I agree to the terms and conditions *
</label>
</div>
{errors.agreedToTerms && (
<span role="alert" className="text-sm text-red-600 block">
{errors.agreedToTerms.message}
</span>
)}
</section>
{/* Submit Button */}
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isSubmitting ? 'Saving...' : 'Save Profile'}
</button>
</form>
)
}
/**
* Async Validation Example
*
* Demonstrates:
* - Async validation with API calls
* - Debouncing to prevent excessive requests
* - Loading states
* - Error handling for async validation
* - Request cancellation
*/
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useState, useRef, useEffect } from 'react'
/**
* Pattern 1: Async Validation in Zod Schema
*/
const usernameSchema = z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must not exceed 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores')
.refine(async (username) => {
// Check if username is available via API
const response = await fetch(`/api/check-username?username=${encodeURIComponent(username)}`)
const { available } = await response.json()
return available
}, {
message: 'Username is already taken',
})
const signupSchemaWithAsync = z.object({
username: usernameSchema,
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
type SignupFormData = z.infer<typeof signupSchemaWithAsync>
export function AsyncValidationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValidating },
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchemaWithAsync),
mode: 'onBlur', // Validate on blur to avoid validating on every keystroke
defaultValues: {
username: '',
email: '',
password: '',
},
})
const onSubmit = async (data: SignupFormData) => {
console.log('Form data:', data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto">
<h2 className="text-2xl font-bold">Sign Up</h2>
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username
</label>
<input
id="username"
{...register('username')}
className="w-full px-3 py-2 border rounded-md"
/>
{isValidating && (
<span className="text-sm text-blue-600 mt-1 block">
Checking availability...
</span>
)}
{errors.username && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.username.message}
</span>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.email && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.email.message}
</span>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
type="password"
{...register('password')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.password && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.password.message}
</span>
)}
</div>
<button
type="submit"
disabled={isSubmitting || isValidating}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isSubmitting ? 'Signing up...' : 'Sign Up'}
</button>
</form>
)
}
/**
* Pattern 2: Manual Async Validation with Debouncing and Cancellation
* Better performance - more control over when validation happens
*/
const manualValidationSchema = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must not exceed 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
email: z.string().email('Invalid email address'),
})
type ManualValidationData = z.infer<typeof manualValidationSchema>
export function DebouncedAsyncValidationForm() {
const {
register,
handleSubmit,
watch,
setError,
clearErrors,
formState: { errors, isSubmitting },
} = useForm<ManualValidationData>({
resolver: zodResolver(manualValidationSchema),
defaultValues: {
username: '',
email: '',
},
})
const [isCheckingUsername, setIsCheckingUsername] = useState(false)
const [isCheckingEmail, setIsCheckingEmail] = useState(false)
const abortControllerRef = useRef<AbortController | null>(null)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const username = watch('username')
const email = watch('email')
// Debounced username validation
useEffect(() => {
// Clear previous timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
// Skip if username is too short (already handled by Zod)
if (!username || username.length < 3) {
setIsCheckingUsername(false)
return
}
// Debounce: wait 500ms after user stops typing
timeoutRef.current = setTimeout(async () => {
// Cancel previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
// Create new abort controller
abortControllerRef.current = new AbortController()
setIsCheckingUsername(true)
clearErrors('username')
try {
const response = await fetch(
`/api/check-username?username=${encodeURIComponent(username)}`,
{ signal: abortControllerRef.current.signal }
)
const { available } = await response.json()
if (!available) {
setError('username', {
type: 'async',
message: 'Username is already taken',
})
}
} catch (error: any) {
if (error.name !== 'AbortError') {
console.error('Username check error:', error)
}
} finally {
setIsCheckingUsername(false)
}
}, 500) // 500ms debounce
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [username, setError, clearErrors])
// Debounced email validation
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
// Basic email validation first (handled by Zod)
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setIsCheckingEmail(false)
return
}
timeoutRef.current = setTimeout(async () => {
setIsCheckingEmail(true)
clearErrors('email')
try {
const response = await fetch(
`/api/check-email?email=${encodeURIComponent(email)}`
)
const { available } = await response.json()
if (!available) {
setError('email', {
type: 'async',
message: 'Email is already registered',
})
}
} catch (error) {
console.error('Email check error:', error)
} finally {
setIsCheckingEmail(false)
}
}, 500)
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [email, setError, clearErrors])
const onSubmit = async (data: ManualValidationData) => {
// Final check before submission
if (isCheckingUsername || isCheckingEmail) {
return
}
console.log('Form data:', data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto">
<h2 className="text-2xl font-bold">Create Account</h2>
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username
</label>
<div className="relative">
<input
id="username"
{...register('username')}
className="w-full px-3 py-2 border rounded-md"
/>
{isCheckingUsername && (
<div className="absolute right-3 top-2.5">
<svg
className="animate-spin h-5 w-5 text-blue-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
)}
</div>
{errors.username && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.username.message}
</span>
)}
{!errors.username && username.length >= 3 && !isCheckingUsername && (
<span className="text-sm text-green-600 mt-1 block">
Username is available ✓
</span>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<div className="relative">
<input
id="email"
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded-md"
/>
{isCheckingEmail && (
<div className="absolute right-3 top-2.5">
<svg
className="animate-spin h-5 w-5 text-blue-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
)}
</div>
{errors.email && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.email.message}
</span>
)}
{!errors.email && email && !isCheckingEmail && (
<span className="text-sm text-green-600 mt-1 block">
Email is available ✓
</span>
)}
</div>
<button
type="submit"
disabled={isSubmitting || isCheckingUsername || isCheckingEmail}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isSubmitting ? 'Creating account...' : 'Create Account'}
</button>
</form>
)
}
/**
* Mock API endpoints for testing
*/
export async function checkUsernameAvailability(username: string): Promise<boolean> {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 1000))
// Mock: usernames starting with 'test' are taken
return !username.toLowerCase().startsWith('test')
}
export async function checkEmailAvailability(email: string): Promise<boolean> {
await new Promise(resolve => setTimeout(resolve, 1000))
// Mock: emails with 'test' are taken
return !email.toLowerCase().includes('test')
}
{
"name": "react-hook-form-zod-example",
"version": "1.0.0",
"description": "Example project demonstrating React Hook Form + Zod validation",
"private": true,
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.66.1",
"zod": "^4.1.12",
"@hookform/resolvers": "^5.2.2"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0",
"vite": "^6.3.0",
"@vitejs/plugin-react": "^4.3.0"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"keywords": [
"react",
"react-hook-form",
"zod",
"validation",
"forms",
"typescript"
],
"author": "",
"license": "MIT"
}
Related skills
How it compares
Pick react-hook-form-zod when you need DRY Zod schemas shared between React Hook Form and server handlers; use generic React skills for non-validated UI layout only.
FAQ
Can one Zod schema validate both client and server?
react-hook-form-zod uses a single Zod schema for React Hook Form on the client and matching server-side validation. z.infer derives TypeScript types from that schema so field values and errors stay consistent across both layers.
Does react-hook-form-zod support shadcn/ui Form components?
react-hook-form-zod covers shadcn/ui Form integration alongside multi-step wizards and useFieldArray for dynamic fields. The skill also addresses resolver mismatches, async validation, and uncontrolled-to-controlled input warnings.
Is React Hook Form Zod safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.