react-hook-form-zod
π―Skillfrom ovachiever/droid-tings
Validates and type-checks React forms using React Hook Form and Zod, ensuring seamless client-side and server-side validation with TypeScript type inference.
Part of
ovachiever/droid-tings(370 items)
Installation
git clone https://github.com/ovachiever/droid-tings.gitSkill Details
|
Overview
# 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
```bash
npm install react-hook-form@7.66.1 zod@4.1.12 @hookform/resolvers@5.2.2
```
Why These Packages:
- react-hook-form: Performant, flexible form library with minimal re-renders
- zod: TypeScript-first schema validation with type inference
- @hookform/resolvers: Adapter to connect Zod (and other validators) to React Hook Form
2. Create Your First Form
```typescript
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
function LoginForm() {
// 3. Initialize form with zodResolver
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm
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 (
{errors.email && ( {errors.email.message} )}
{errors.password && ( {errors.password.message} )}
{isSubmitting ? 'Logging in...' : 'Login'}
)
}
```
CRITICAL:
- Always set
defaultValuesto prevent "uncontrolled to controlled" warnings - Use
zodResolver(schema)to connect Zod validation - Type form with
z.inferfor full type safety - Validate on both client AND server (never trust client validation alone)
3. Add Server-Side Validation
```typescript
// 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
```typescript
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
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
```typescript
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:
```typescript
const userSchema = z.object({
name: z.string(),
age: z.number(),
})
// Automatically infer TypeScript type
type User = z.infer
// Result: { name: string; age: number }
```
Zod Refinements (Custom Validation)
```typescript
// 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)
```typescript
// 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
```typescript
import { zodResolver } from '@hookform/resolvers/zod'
const form = useForm
resolver: zodResolver(schema),
})
```
What zodResolver Does:
- Takes your Zod schema
- Converts it to a format React Hook Form understands
- Provides validation function that runs on form submission
- Maps Zod errors to React Hook Form error format
- Preserves type safety with TypeScript inference
zodResolver Options:
```typescript
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
```typescript
function BasicForm() {
const { register, handleSubmit } = useForm
resolver: zodResolver(schema),
})
return (
{/ Spread register result to input /}
{/ With custom props /}
{...register('username')}
placeholder="Enter username"
className="input"
/>
)
}
```
What register() Returns:
```typescript
{
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.):
```typescript
import { Controller } from 'react-hook-form'
function FormWithCustomInput() {
const { control, handleSubmit } = useForm
resolver: zodResolver(schema),
})
return (
name="category"
control={control}
render={({ field }) => (
{...field} // value, onChange, onBlur, ref
options={categoryOptions}
/>
)}
/>
{/ With more control /}
name="dateOfBirth"
control={control}
render={({ field, fieldState }) => (
selected={field.value} onChange={field.onChange} onBlur={field.onBlur} /> {fieldState.error && ( {fieldState.error.message} )}
)}
/>
)
}
```
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)
```typescript
import { useController } from 'react-hook-form'
// Reusable custom input component
function CustomInput({ name, control, label }) {
const {
field,
fieldState: { error },
} = useController({
name,
control,
defaultValue: '',
})
return (
{error && {error.message}}
)
}
// Usage
function MyForm() {
const { control, handleSubmit } = useForm({
resolver: zodResolver(schema),
})
return (
)
}
```
---
Error Handling
Displaying Errors
```typescript
function FormWithErrors() {
const { register, handleSubmit, formState: { errors } } = useForm
resolver: zodResolver(schema),
})
return (
{/ Simple error display /} {errors.email && {errors.email.message}} {/ Accessible error display /} {errors.email && ( {errors.email.message} )} {/ Error with icon /} {errors.email && ( {errors.email.message} )}
)
}
```
Error Object Structure
```typescript
// 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
```typescript
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 error
```
Server Errors Integration
```typescript
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 (
{errors.root &&
{/ ... /}
)
}
```
---
Advanced Patterns
Dynamic Form Fields (useFieldArray)
```typescript
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
function ContactListForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm
resolver: zodResolver(contactSchema),
defaultValues: {
contacts: [{ name: '', email: '' }],
},
})
const { fields, append, remove } = useFieldArray({
control,
name: 'contacts',
})
return (
{fields.map((field, index) => (
{...register( placeholder="Name" /> {errors.contacts?.[index]?.name && ( {errors.contacts[index].name.message} )} {...register( placeholder="Email" /> {errors.contacts?.[index]?.email && ( {errors.contacts[index].email.message} )} Remove contacts.${index}.name as const)}contacts.${index}.email as const)}
))}
type="button"
onClick={() => append({ name: '', email: '' })}
>
Add Contact
)
}
```
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
```typescript
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 (
{...register('username')}
onChange={(e) => {
register('username').onChange(e)
debouncedValidation()
}}
/>
{isValidating && Checking availability...}
{errors.username && {errors.username.message}}
)
}
```
Multi-Step Form (Wizard)
```typescript
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
function MultiStepForm() {
const [step, setStep] = useState(1)
const { register, handleSubmit, trigger, formState: { errors } } = useForm
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 (
{/ Progress indicator /}
Step {step} of 3
{/ Step 1 /}
{step === 1 && (
{errors.name && {errors.name.message}} {errors.email && {errors.email.message}} Personal Information
)}
{/ Step 2 /}
{step === 2 && (
{errors.address && {errors.address.message}} {errors.city && {errors.city.message}} Address
)}
{/ Step 3 /}
{step === 3 && (
{errors.cardNumber && {errors.cardNumber.message}} {errors.cvv && {errors.cvv.message}} Payment
)}
{/ Navigation /}
{step > 1 && ( Previous )} {step < 3 ? ( Next ) : ( )}
)
}
```
Conditional Validation
```typescript
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)
```typescript
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
resolver: zodResolver(formSchema),
defaultValues: {
username: '',
email: '',
},
})
return (
control={form.control}
name="username"
render={({ field }) => (
This is your public display name.
)}
/>
control={form.control}
name="email"
render={({ field }) => (
)}
/>
)
}
```
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
```typescript
// 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
```typescript
// Uncontrolled (better performance) - use register
// Controlled (more control) - use Controller
name="email"
control={control}
render={({ field }) => }
/>
```
Recommendation: Use register for standard inputs, Controller only when necessary (third-party components, custom behavior).
Isolation with Controller
```typescript
// BAD: Entire form re-renders when any field changes
function BadForm() {
const { watch } = useForm()
const values = watch() // Watches ALL fields
return
}
// GOOD: Only re-render when specific field changes
function GoodForm() {
const { watch } = useForm()
const email = watch('email') // Watches only email field
return
}
```
shouldUnregister Flag
```typescript
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
```typescript
function AccessibleForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
})
return (
id="email" {...register('email')} aria-invalid={errors.email ? 'true' : 'false'} aria-describedby={errors.email ? 'email-error' : undefined} /> {errors.email && ( {errors.email.message} )}
)
}
```
Error Announcements
```typescript
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 (
{/ ... /}
)
}
```
Focus Management
```typescript
import { useRef, useEffect } from 'react'
function FormWithFocus() {
const { handleSubmit, formState: { errors } } = useForm()
const firstErrorRef = useRef
// Focus first error field on validation failure
useEffect(() => {
if (Object.keys(errors).length > 0) {
firstErrorRef.current?.focus()
}
}, [errors])
return (
{...register('email')}
ref={errors.email ? firstErrorRef : undefined}
/>
)
}
```
---
Critical Rules
Always Do
β Set defaultValues to prevent "uncontrolled to controlled" warnings
```typescript
const form = useForm({
defaultValues: { email: '', password: '' }, // ALWAYS set defaults
})
```
β Use zodResolver for Zod integration
```typescript
const form = useForm({
resolver: zodResolver(schema), // Required for Zod validation
})
```
β Type forms with z.infer
```typescript
type FormData = z.infer
```
β Validate on both client AND server
```typescript
// Client
const form = useForm({ resolver: zodResolver(schema) })
// Server
const data = schema.parse(await req.json()) // SAME schema
```
β Use formState.errors for error display
```typescript
{errors.email && {errors.email.message}}
```
β Add ARIA attributes for accessibility
```typescript
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby="email-error"
/>
```
β Use field.id for useFieldArray keys
```typescript
{fields.map((field) =>
```
β Debounce async validation
```typescript
const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)
```
Never Do
β Skip server-side validation (security vulnerability!)
```typescript
// 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
```typescript
// Issue #13109: Zod v4 has type inference changes
// Test your types carefully when upgrading
```
β Forget to spread {...field} in Controller
```typescript
// BAD
// GOOD
```
β Mutate form values directly
```typescript
// BAD
const values = getValues()
values.email = 'new@email.com' // Direct mutation
// GOOD
setValue('email', 'new@email.com') // Use setValue
```
β Use inline validation without debouncing
```typescript
// BAD: Validates on every keystroke
const form = useForm({ mode: 'onChange' })
// GOOD: Debounce async validation
const debouncedTrigger = useDebouncedCallback(() => trigger(), 500)
```
β Mix controlled and uncontrolled inputs
```typescript
// BAD: Mixing patterns
// GOOD: Choose one pattern
// Uncontrolled
// OR
```
β Use index as key in useFieldArray
```typescript
// BAD
{fields.map((field, index) =>
// GOOD
{fields.map((field) =>
```
β Forget defaultValues for all fields
```typescript
// 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](https://github.com/react-hook-form/react-hook-form/issues/13109) (Closed 2025-11-01)
Why It Happens: Zod v4 changed how types are inferred
Prevention: Use correct type patterns: type FormData = z.infer
Note: Resolved in react-hook-form v7.66.x+. Upgrade to latest version to avoid this issue.
Issue #2: Uncontrolled to Controlled Warning
Error: "A component is changing an uncontrolled input to be controlled"
Source: React documentation
Why It Happens: Not setting defaultValues causes undefined -> value transition
Prevention: Always set defaultValues for all fields
Issue #3: Nested Object Validation Errors
Error: Errors for nested fields don't display correctly
Source: Common React Hook Form issue
Why It Happens: Accessing nested errors incorrectly
Prevention: Use optional chaining: errors.address?.street?.message
Issue #4: Array Field Re-renders
Error: Form re-renders excessively with array fields
Source: Performance issue
Why It Happens: Not using field.id as key
Prevention: Use key={field.id} in useFieldArray map
Issue #5: Async Validation Race Conditions
Error: Multiple validation requests cause conflicting results
Source: Common async pattern issue
Why It Happens: No debouncing or request cancellation
Prevention: Debounce validation and cancel pending requests
Issue #6: Server Error Mapping
Error: Server validation errors don't map to form fields
Source: Integration issue
Why It Happens: Server error format doesn't match React Hook Form format
Prevention: Use setError() to map server errors to fields
Issue #7: Default Values Not Applied
Error: Form fields don't show default values
Source: Common mistake
Why It Happens: defaultValues set after form initialization
Prevention: Set defaultValues in useForm options, not useState
Issue #8: Controller Field Not Updating
Error: Custom component doesn't update when value changes
Source: Common Controller issue
Why It Happens: Not spreading {...field} in render function
Prevention: Always spread {...field} to custom component
Issue #9: useFieldArray Key Warnings
Error: React warning about duplicate keys in list
Source: React list rendering
Why It Happens: Using array index as key instead of field.id
Prevention: Use field.id: key={field.id}
Issue #10: Schema Refinement Error Paths
Error: Custom validation errors appear at wrong field
Source: Zod refinement behavior
Why It Happens: Not specifying path in refinement options
Prevention: Add path option: refine(..., { message: '...', path: ['fieldName'] })
Issue #11: Transform vs Preprocess Confusion
Error: Data transformation doesn't work as expected
Source: Zod API confusion
Why It Happens: Using wrong method for use case
Prevention: Use transform for output transformation, preprocess for input transformation
Issue #12: Multiple Resolver Conflicts
Error: Form validation doesn't work with multiple resolvers
Source: Configuration error
Why It Happens: Trying to use multiple validation libraries
Prevention: Use single resolver (zodResolver), combine schemas if needed
---
Templates
See the templates/ directory for working examples:
- basic-form.tsx - Simple login/signup form
- advanced-form.tsx - Nested objects, arrays, conditional fields
- shadcn-form.tsx - shadcn/ui Form component integration
- server-validation.ts - Server-side validation with same schema
- async-validation.tsx - Async validation with debouncing
- dynamic-fields.tsx - useFieldArray for adding/removing items
- multi-step-form.tsx - Wizard with per-step validation
- custom-error-display.tsx - Custom error formatting
- package.json - Complete dependencies
---
References
See the references/ directory for deep-dive documentation:
- zod-schemas-guide.md - Comprehensive Zod schema patterns
- rhf-api-reference.md - Complete React Hook Form API
- error-handling.md - Error messages, formatting, accessibility
- accessibility.md - WCAG compliance, ARIA attributes
- performance-optimization.md - Form modes, validation strategies
- shadcn-integration.md - shadcn/ui Form vs Field components
- top-errors.md - 12 common errors with solutions
- links-to-official-docs.md - Organized documentation links
---
Official Documentation
- 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)
More from this repository10
nextjs-shadcn-builder skill from ovachiever/droid-tings
security-auditor skill from ovachiever/droid-tings
threejs-graphics-optimizer skill from ovachiever/droid-tings
api-documenter skill from ovachiever/droid-tings
secret-scanner skill from ovachiever/droid-tings
readme-updater skill from ovachiever/droid-tings
applying-brand-guidelines skill from ovachiever/droid-tings
Configures Tailwind v4 with shadcn/ui, automating CSS variable setup, dark mode, and preventing common initialization errors.
deep-reading-analyst skill from ovachiever/droid-tings
dependency-auditor skill from ovachiever/droid-tings