react-hook-form-zod
React Hook Form + Zod Validation
Status: Production Ready ✅ Last Updated: 2025-11-21 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
bun add react-hook-form@7.66.1 zod@4.1.12 @hookform/resolvers@5.2.2
Why These Packages:
- react-hook-form: Performant, flexible forms with minimal re-renders
- zod: TypeScript-first schema validation with type inference
- @hookform/resolvers: Adapter connecting Zod 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)
}
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)
Template: See templates/basic-form.tsx for complete working example
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 {
const data = loginSchema.parse(await req.json())
// Data is type-safe and validated
return { success: true }
} catch (error) {
if (error instanceof z.ZodError) {
return { success: false, errors: error.flatten().fieldErrors }
}
throw error
}
}
Why Server Validation:
- Client validation can be bypassed (inspect element, Postman, curl)
- Server validation is your security layer
- Same Zod schema = single source of truth
Template: See templates/server-validation.ts
Core Concepts
useForm Hook
const {
register, // Register input fields
handleSubmit, // Wrap onSubmit handler
formState, // Form state (errors, isValid, isDirty, etc.)
setValue, // Set field value programmatically
getValues, // Get current form values
watch, // Watch field values
reset, // Reset form to defaults
trigger, // Trigger validation manually
control, // For Controller/useController
} = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onSubmit', // When to validate
defaultValues: {}, // Initial values (REQUIRED)
})
Validation Modes:
onSubmit- Validate on submit (best performance)onChange- Validate on every change (live feedback)onBlur- Validate when field loses focus (good balance)all- Validate on submit, blur, and change
Reference: See references/rhf-api-reference.md for complete API
Zod Schema Basics
import { z } from 'zod'
// Basic types
const schema = z.object({
email: z.string().email('Invalid email'),
age: z.number().min(18, 'Must be 18+'),
terms: z.boolean().refine(val => val === true, 'Must accept terms'),
})
// Nested objects
const addressSchema = z.object({
user: z.object({
name: z.string(),
email: z.string().email(),
}),
address: z.object({
street: z.string(),
city: z.string(),
zip: z.string().regex(/^\d{5}$/),
}),
})
// Arrays
const tagsSchema = z.object({
tags: z.array(z.string()).min(1, 'At least one tag required'),
})
// Optional and nullable
const optionalSchema = z.object({
middleName: z.string().optional(),
nickname: z.string().nullable(),
bio: z.string().nullish(), // optional AND nullable
})
Reference: See references/zod-schemas-guide.md for complete patterns
Critical Rules
Always Do
✅ Always set defaultValues - Prevents "uncontrolled to controlled" warnings
✅ Use zodResolver for validation - Connects Zod schemas to React Hook Form
✅ Infer types from schema - Use z.infer<typeof schema> for type safety
✅ Validate on server too - Client validation can be bypassed
✅ Use .register() for native inputs - Simple and performant
✅ Use Controller for custom components - For component libraries (MUI, Chakra, etc.)
✅ Handle errors accessibly - Use role="alert" for screen readers
✅ Reset form after submission - Use reset() to clear form state
Form Patterns: See templates/ for:
basic-form.tsx- Simple login/register formsadvanced-form.tsx- Nested objects, arrays, dynamic fieldsshadcn-form.tsx- Integration with shadcn/uimulti-step-form.tsx- Wizard/stepper formsasync-validation.tsx- Async field validation
Never Do
❌ Never skip defaultValues - Causes "uncontrolled to controlled" errors
❌ Never use only client validation - Security vulnerability
❌ Never mutate form values directly - Use setValue() instead
❌ Never ignore accessibility - Always use proper labels and ARIA
❌ Never forget to disable submit when isSubmitting - Prevents double submissions
Performance: See references/performance-optimization.md for:
- When to use
mode: 'onBlur'vs'onChange' useWatchvswatch()- Re-render optimization strategies
Accessibility: See references/accessibility.md for:
- Proper label association
- Error announcement
- Focus management
- Keyboard navigation
Top 5 Critical Errors
Error #1: Uncontrolled to Controlled Warning ⚠️
Error:
Warning: A component is changing an uncontrolled input to be controlled
Cause: Not setting defaultValues
Solution:
// ❌ BAD
const form = useForm()
// ✅ GOOD
const form = useForm({
defaultValues: {
email: '',
password: '',
}
})
Error #2: Zod v4 Type Inference Issues
Error: Type inference doesn't work correctly
Solution:
// Explicitly type useForm if needed
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
})
Source: GitHub Issue #13109
Error #3: Resolver Not Found
Error:
Module not found: Can't resolve '@hookform/resolvers/zod'
Solution:
# Install the resolvers package
bun add @hookform/resolvers@5.2.2
Error #4: Array Field Issues
Error: Dynamic array fields not working with useFieldArray
Solution:
const { fields, append, remove } = useFieldArray({
control,
name: "items" // Must match schema field name exactly
})
Template: See templates/dynamic-fields.tsx
Error #5: Custom Component Validation Fails
Error: Third-party component (MUI, Chakra) doesn't validate
Solution:
Use Controller instead of register:
<Controller
name="date"
control={control}
render={({ field }) => (
<DatePicker {...field} />
)}
/>
Reference: See references/error-handling.md for all patterns
All 12 Errors: See references/top-errors.md for complete documentation
Common Patterns
Basic Form
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const schema = z.object({
name: z.string().min(1, 'Name required'),
email: z.string().email('Invalid email'),
})
type FormData = z.infer<typeof schema>
function MyForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { name: '', email: '' }
})
const onSubmit = (data: FormData) => console.log(data)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<button type="submit">Submit</button>
</form>
)
}
Template: See templates/basic-form.tsx
Dynamic Fields (useFieldArray)
import { useForm, useFieldArray } from 'react-hook-form'
const schema = z.object({
items: z.array(
z.object({
name: z.string(),
quantity: z.number().min(1)
})
).min(1, 'At least one item required')
})
function DynamicForm() {
const { control, handleSubmit } = useForm({
resolver: zodResolver(schema),
defaultValues: { items: [{ name: '', quantity: 1 }] }
})
const { fields, append, remove } = useFieldArray({
control,
name: 'items'
})
return (
<form>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
<button onClick={() => remove(index)}>Remove</button>
</div>
))}
<button onClick={() => append({ name: '', quantity: 1 })}>
Add Item
</button>
</form>
)
}
Template: See templates/dynamic-fields.tsx
Async Validation
const schema = z.object({
username: z.string()
.min(3)
.refine(async (username) => {
const response = await fetch(`/api/check-username?username=${username}`)
const { available } = await response.json()
return available
}, 'Username already taken')
})
Template: See templates/async-validation.tsx
Multi-Step Form
function MultiStepForm() {
const [step, setStep] = useState(1)
const form = useForm({
resolver: zodResolver(schema),
mode: 'onBlur' // Validate each step before proceeding
})
const onSubmit = async (data) => {
if (step < 3) {
setStep(step + 1)
} else {
// Final submission
await submitForm(data)
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{step === 1 && <Step1Fields />}
{step === 2 && <Step2Fields />}
{step === 3 && <Step3Fields />}
<button type="submit">
{step < 3 ? 'Next' : 'Submit'}
</button>
</form>
)
}
Template: See templates/multi-step-form.tsx
shadcn/ui Integration
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
function ShadcnForm() {
const form = useForm({
resolver: zodResolver(schema),
defaultValues: { email: '' }
})
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)
}
Reference: See references/shadcn-integration.md for complete patterns
Template: See templates/shadcn-form.tsx
Using Bundled Resources
Templates (templates/)
Copy-paste ready examples:
- basic-form.tsx - Simple login/register forms with validation
- advanced-form.tsx - Nested objects, arrays, conditional fields
- shadcn-form.tsx - shadcn/ui Form component integration
- multi-step-form.tsx - Wizard/stepper forms with step validation
- dynamic-fields.tsx - useFieldArray for dynamic form fields
- async-validation.tsx - Async field validation (username check, etc.)
- server-validation.ts - Server-side validation with Zod
- custom-error-display.tsx - Custom error message components
- package.json - Package versions and scripts
References (references/)
Detailed documentation:
- top-errors.md - All 12 common errors with solutions and sources
- rhf-api-reference.md - Complete React Hook Form API reference
- zod-schemas-guide.md - Comprehensive Zod schema patterns
- shadcn-integration.md - shadcn/ui Form integration guide
- error-handling.md - Error display patterns and accessibility
- performance-optimization.md - Re-render optimization strategies
- accessibility.md - WCAG compliance and screen reader support
- links-to-official-docs.md - Organized official documentation links
When to Load References
| Reference | Load When... |
|---|---|
top-errors.md |
Debugging validation issues, type errors, or "uncontrolled to controlled" warnings |
rhf-api-reference.md |
Need complete API for useForm, register, Controller, formState |
zod-schemas-guide.md |
Building complex schemas (nested, arrays, conditional, async validation) |
shadcn-integration.md |
Using shadcn/ui Form, FormField, FormItem components |
error-handling.md |
Custom error display, validation timing, error message patterns |
performance-optimization.md |
Form re-renders too much, optimizing watch/useWatch |
accessibility.md |
WCAG compliance, screen readers, keyboard navigation |
links-to-official-docs.md |
Need official documentation links |
Performance Tips
Quick Tips:
- Use
mode: 'onBlur'for balance between UX and performance - Use
useWatchinstead ofwatch()for specific fields - Memoize validation schemas outside component
- Use
shouldUnregister: falsefor conditional fields - Avoid
watch()without arguments (watches all fields)
Reference: See references/performance-optimization.md for complete strategies
Accessibility
Quick Checklist:
- ✅ Use
<label htmlFor="fieldId">for all inputs - ✅ Add
role="alert"to error messages - ✅ Use
aria-invalid="true"on invalid fields - ✅ Ensure keyboard navigation works (Tab, Enter, Escape)
- ✅ Provide clear, actionable error messages
Reference: See references/accessibility.md for WCAG compliance guide
Validation Schemas (Zod)
Common Patterns:
// Email
z.string().email('Invalid email')
// Password (min 8 chars, 1 uppercase, 1 number)
z.string()
.min(8)
.regex(/[A-Z]/, 'Need uppercase')
.regex(/[0-9]/, 'Need number')
// URL
z.string().url('Invalid URL')
// Date
z.string().datetime() // ISO 8601
z.date() // JS Date object
// File upload
z.instanceof(File)
.refine(file => file.size <= 5000000, 'Max 5MB')
.refine(
file => ['image/jpeg', 'image/png'].includes(file.type),
'Only JPEG/PNG allowed'
)
// Custom validation
z.string().refine(
val => val !== 'admin',
'Username "admin" is reserved'
)
// Async validation
z.string().refine(
async (username) => {
const available = await checkUsername(username)
return available
},
'Username already taken'
)
Reference: See references/zod-schemas-guide.md for all patterns
Dependencies
Required:
react-hook-form@7.65.0- Form state managementzod@4.1.12- Schema validation@hookform/resolvers@5.2.2- Validation adapter
Optional:
@radix-ui/react-label@latest- For shadcn/ui integrationclass-variance-authority@latest- For shadcn/ui styling
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
- GitHub: https://github.com/react-hook-form/react-hook-form
Reference: See references/links-to-official-docs.md for organized links
Troubleshooting
"Uncontrolled to controlled" warning
Solution: Always set defaultValues → See references/top-errors.md #2
Type inference issues with Zod v4
Solution: Explicitly type useForm<z.infer<typeof schema>> → See references/top-errors.md #1
Resolver not found error
Solution: Install @hookform/resolvers package → See references/top-errors.md #3
Custom component doesn't validate
Solution: Use Controller instead of register → See references/top-errors.md #5
Form re-renders too much
Solution: Use mode: 'onBlur' and useWatch → See references/performance-optimization.md
Production Example
This skill is based on production patterns from:
- Real-world forms: Login, registration, checkout, multi-step wizards
- Validation: Client + server with shared Zod schemas
- Accessibility: WCAG 2.1 AA compliant
- Performance: Optimized for minimal re-renders
Token Savings: ~60% (comprehensive form patterns with templates) Error Prevention: 100% (all 12 documented issues with solutions) Ready for production! ✅
More from nguyenvanchiens/my-skills
gitlab-flow
Standard end-to-end workflow for shipping a feature/bugfix from a Jira task to a merged GitLab MR. Use when the user references a Jira task ID (WRA-XX, etc.), asks to "start a task", "create branch from task", "review the last change", "review the whole branch", "commit and push", "create a merge request", "review the MR !N", "post review result to the MR", "fix all issues", or "merge the request". Covers branch naming, commit format, MR creation, micro + macro code review (3-agent parallel), fix loop, and merge.
13impeccable
Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
11karpathy-guidelines
Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
10review-branch
Review the cumulative changes of the current branch against main (committed + uncommitted) for reuse, quality, and efficiency, then fix any issues found. Use when finishing a feature branch before opening an MR.
4commit
Commit staged + working-tree changes following Conventional Commits, with the Jira ID as the first token on the subject line. Takes the Jira ID as an argument, e.g. `/commit WRA-9`.
4blazor
Build Blazor apps on .NET 8+: unified Blazor Web App với render modes (SSR, Stream, Server, WebAssembly, Auto), components/parameters/EventCallback, lifecycle, EditForm validation, JS interop, state management. Use when project has .razor files, Components/ folder, App.razor, MainLayout.razor, or _Imports.razor.
1