react-hook-form-zod
Install this skill
npx skills add jezweb/claude-skillsWorks across Claude Code, Cursor, Codex, Copilot & Antigravity
React Hook Form combined with Zod provides a type-safe approach to form management and validation in React applications. By using the @hookform/resolvers package, developers bind Zod schemas directly to forms, ensuring that input data adheres to specific requirements before reaching the submit handler. This integration allows for shared schema definitions between the frontend and the backend, eliminating redundant validation logic. It handles complex scenarios like nested objects, array manipulation via useFieldArray, and conditional field logic through Zod's refinement and union types. The ecosystem reduces boilerplate while maintaining strict TypeScript definitions for form values, error states, and submission payloads. It is the standard approach for ensuring data integrity in forms, specifically when avoiding the overhead of manual change handlers and uncontrolled state management inside component trees.
When to Use This Skill
- •Multi-step registration forms with varying schema requirements
- •Forms involving complex nested structures or dynamic lists
- •Applications sharing validation rules between Node.js backends and React frontends
- •Projects requiring strict type safety for form submission data
How to Invoke This Skill
Example prompts that trigger this skill in Claude Code, Cursor, or Antigravity:
- “How do I validate React Hook Form with Zod?
- “Setup form validation with zodResolver
- “Handle server side validation errors in react-hook-form
- “Create dynamic field arrays with Zod validation
- “How to share Zod schemas between backend and frontend
Pro Tips
- 💡Always define `defaultValues` in `useForm` options to prevent 'uncontrolled component' warnings and ensure predictable form state, especially during resets.
- 💡Utilize `mode: 'onBlur'` for a good balance between performance and user feedback, reserving `'onChange'` for highly interactive fields where immediate validation is crucial.
- 💡Crucially, mirror your Zod schema on the server-side as well to guarantee data integrity and prevent malicious submissions, even if client-side validation passes.
What this skill does
- •Schema-based validation using Zod resolvers
- •Automatic TypeScript type inference for form values
- •Complex cross-field and conditional logic validation
- •Dynamic form field management via useFieldArray
- •Integrated server-side error mapping to UI inputs
When not to use it
- ✕Simple one-input forms where standard HTML validation suffices
- ✕Highly interactive canvas-based interfaces that do not rely on DOM inputs
Example workflow
- Define a Zod schema object specifying required fields and constraints
- Initialize useForm with the zodResolver pointing to the defined schema
- Register inputs to the form using the register hook or Controller
- Handle form submission with validated data via the handleSubmit function
- Map server-side validation error responses back to form fields using setError
Prerequisites
- –react-hook-form
- –zod
- –@hookform/resolvers
Pitfalls & limitations
- !Forgetting to set defaultValues leads to uncontrolled component warnings
- !Incorrectly identifying dynamic list items by index instead of unique IDs in useFieldArray
- !Failing to provide a ref to custom components when not using the Controller wrapper
FAQ
How it compares
Doing this manually requires writing custom change handlers and redundant state logic, whereas this approach centralizes all rules in a single declarative schema.
📄 Full skill instructions — original source: jezweb/claude-skills
**Status**: Production Ready ✅
**Last Verified**: 2026-01-20
**Latest Versions**: [email protected], [email protected], @hookform/[email protected]
---
## Quick Start
npm install [email protected] [email protected] @hookform/[email protected]**Basic Form Pattern**:
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
type FormData = z.infer<typeof schema>
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '' }, // REQUIRED to prevent uncontrolled warnings
})
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span role="alert">{errors.email.message}</span>}
</form>**Server Validation** (CRITICAL - never skip):
// SAME schema on server
const data = schema.parse(await req.json())---
## Key Patterns
**useForm Options** (validation modes):
-
mode: 'onSubmit' (default) - Best performance-
mode: 'onBlur' - Good balance-
mode: 'onChange' - Live feedback, more re-renders-
shouldUnregister: true - Remove field data when unmounted (use for multi-step forms)**Zod Refinements** (cross-field validation):
z.object({ password: z.string(), confirm: z.string() })
.refine((data) => data.password === data.confirm, {
message: "Passwords don't match",
path: ['confirm'], // CRITICAL: Error appears on this field
})**Zod Transforms**:
z.string().transform((val) => val.toLowerCase()) // Data manipulation
z.string().transform(parseInt).refine((v) => v > 0) // Chain with refine**Zod v4.3.0+ Features**:
// Exact optional (can omit field, but NOT undefined)
z.string().exactOptional()
// Exclusive union (exactly one must match)
z.xor([z.string(), z.number()])
// Import from JSON Schema
z.fromJSONSchema({ type: "object", properties: { name: { type: "string" } } })**zodResolver** connects Zod to React Hook Form, preserving type safety
---
## Registration
**register** (for standard HTML inputs):
<input {...register('email')} /> // Uncontrolled, best performance**Controller** (for third-party components):
<Controller
name="category"
control={control}
render={({ field }) => <CustomSelect {...field} />} // MUST spread {...field}
/>**When to use Controller**: React Select, date pickers, custom components without ref. Otherwise use
register.---
## Error Handling
**Display errors**:
{errors.email && <span role="alert">{errors.email.message}</span>}
{errors.address?.street?.message} // Nested errors (use optional chaining)**Server errors**:
const onSubmit = async (data) => {
const res = await fetch('/api/submit', { method: 'POST', body: JSON.stringify(data) })
if (!res.ok) {
const { errors: serverErrors } = await res.json()
Object.entries(serverErrors).forEach(([field, msg]) => setError(field, { message: msg }))
}
}---
## Advanced Patterns
**useFieldArray** (dynamic lists):
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' })
{fields.map((field, index) => (
<div key={field.id}> {/* CRITICAL: Use field.id, NOT index */}
<input {...register(contacts.${index}.name as const)} />
{errors.contacts?.[index]?.name && <span>{errors.contacts[index].name.message}</span>}
<button onClick={() => remove(index)}>Remove</button>
</div>
))}
<button onClick={() => append({ name: '', email: '' })}>Add</button>**Async Validation** (debounce):
const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)**Multi-Step Forms**:
const step1 = z.object({ name: z.string(), email: z.string().email() })
const step2 = z.object({ address: z.string() })
const fullSchema = step1.merge(step2)
const nextStep = async () => {
const isValid = await trigger(['name', 'email']) // Validate specific fields
if (isValid) setStep(2)
}**Conditional Validation**:
z.discriminatedUnion('accountType', [
z.object({ accountType: z.literal('personal'), name: z.string() }),
z.object({ accountType: z.literal('business'), companyName: z.string() }),
])**Conditional Fields with shouldUnregister**:
const form = useForm({
resolver: zodResolver(schema),
shouldUnregister: false, // Keep values when fields unmount (default)
})
// Or use conditional schema validation:
z.object({
showAddress: z.boolean(),
address: z.string(),
}).refine((data) => {
if (data.showAddress) {
return data.address.length > 0;
}
return true;
}, {
message: "Address is required",
path: ["address"],
})---
## shadcn/ui Integration
**Note**: shadcn/ui deprecated the Form component. Use the Field component for new implementations (check latest docs).
**Common Import Mistake**: IDEs/AI may auto-import
Form from "react-hook-form" instead of from shadcn. Always import:// ✅ Correct:
import { useForm } from "react-hook-form";
import { Form, FormField, FormItem } from "@/components/ui/form"; // shadcn
// ❌ Wrong (auto-import mistake):
import { useForm, Form } from "react-hook-form";**Legacy Form component**:
<FormField control={form.control} name="username" render={({ field }) => (
<FormItem>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />---
## Performance
- Use
register (uncontrolled) over Controller (controlled) for standard inputs- Use
watch('email') not watch() (isolates re-renders to specific fields)-
shouldUnregister: true for multi-step forms (clears data on unmount)### Large Forms (300+ Fields)
**Warning**: Forms with 300+ fields using a resolver (Zod/Yup) AND reading
formState properties can freeze for 10-15 seconds during registration. ([Issue #13129](https://github.com/react-hook-form/react-hook-form/issues/13129))**Performance Characteristics**:
- Clean (no resolver, no formState read): Almost immediate
- With resolver only: Almost immediate
- With formState read only: Almost immediate
- With BOTH resolver + formState read: ~9.5 seconds for 300 fields
**Workarounds**:
1. **Avoid destructuring formState** - Read properties inline only when needed:
// ❌ Slow with 300+ fields:
const { isDirty, isValid } = form.formState;
// ✅ Fast:
const handleSubmit = () => {
if (!form.formState.isValid) return; // Read inline only when needed
};2. **Use mode: "onSubmit"** - Don't validate on every change:
const form = useForm({
resolver: zodResolver(largeSchema),
mode: "onSubmit", // Validate only on submit, not onChange
});3. **Split into sub-forms** - Multiple smaller forms with separate schemas:
// Instead of one 300-field form, use 5-6 forms with 50-60 fields each
const form1 = useForm({ resolver: zodResolver(schema1) }); // Fields 1-50
const form2 = useForm({ resolver: zodResolver(schema2) }); // Fields 51-1004. **Lazy render fields** - Use tabs/accordion to mount only visible fields:
// Only mount fields for active tab, reduces initial registration time
{activeTab === 'personal' && <PersonalInfoFields />}
{activeTab === 'address' && <AddressFields />}---
## Critical Rules
✅ **Always set defaultValues** (prevents uncontrolled→controlled warnings)
✅ **Validate on BOTH client and server** (client can be bypassed - security!)
✅ **Use
field.id as key** in useFieldArray (not index)✅ **Spread
{...field}** in Controller render✅ **Use
z.infer<typeof schema>** for type inference❌ **Never skip server validation** (security vulnerability)
❌ **Never mutate values directly** (use
setValue())❌ **Never mix controlled + uncontrolled** patterns
❌ **Never use index as key** in useFieldArray
---
## Known Issues (20 Prevented)
1. **Zod v4 Type Inference** - [#13109](https://github.com/react-hook-form/react-hook-form/issues/13109): Use
z.infer<typeof schema> explicitly. Resolved in v7.66.x+. **Note**: @hookform/resolvers has TypeScript compatibility issues with Zod v4 ([#813](https://github.com/react-hook-form/resolvers/issues/813)). Workaround: Use import { z } from 'zod/v3' or wait for resolver update.2. **Uncontrolled→Controlled Warning** - Always set
defaultValues for all fields3. **Nested Object Errors** - Use optional chaining:
errors.address?.street?.message4. **Array Field Re-renders** - Use
key={field.id} in useFieldArray (not index)5. **Async Validation Race Conditions** - Debounce validation, cancel pending requests
6. **Server Error Mapping** - Use
setError() to map server errors to fields7. **Default Values Not Applied** - Set
defaultValues in useForm options (not useState)8. **Controller Field Not Updating** - Always spread
{...field} in render function9. **useFieldArray Key Warnings** - Use
field.id as key (not index)10. **Schema Refinement Error Paths** - Specify
path in refinement: refine(..., { path: ['fieldName'] })11. **Transform vs Preprocess** - Use
transform for output, preprocess for input12. **Multiple Resolver Conflicts** - Use single resolver (zodResolver), combine schemas if needed
13. **Zod v4 Optional Fields Bug** - [#13102](https://github.com/react-hook-form/react-hook-form/issues/13102): Setting optional fields (
.optional()) to empty string "" incorrectly triggers validation errors. Workarounds: Use .nullish(), .or(z.literal("")), or z.preprocess((val) => val === "" ? undefined : val, z.email().optional())14. **useFieldArray Primitive Arrays Not Supported** - [#12570](https://github.com/react-hook-form/react-hook-form/issues/12570): Design limitation.
useFieldArray only works with arrays of objects, not primitives like string[]. Workaround: Wrap primitives in objects: [{ value: "string" }] instead of ["string"]15. **useFieldArray SSR ID Mismatch** - [#12782](https://github.com/react-hook-form/react-hook-form/issues/12782): Hydration mismatch warnings with SSR (Remix, Next.js). Field IDs generated on server don't match client. Workaround: Use client-only rendering for field arrays or wait for V8 (uses deterministic
key)16. **Next.js 16 reset() Validation Bug** - [#13110](https://github.com/react-hook-form/react-hook-form/issues/13110): Calling
form.reset() after Server Actions submission causes validation errors on next submit. Fixed in v7.65.0+. Before fix: Use setValue() instead of reset()17. **Validation Race Condition** - [#13156](https://github.com/react-hook-form/react-hook-form/issues/13156): During resolver validation, intermediate render where
isValidating=false but errors not populated yet. Don't derive validity from errors alone. Use: !errors.field && !isValidating18. **ZodError Thrown in Beta Versions** - [#12816](https://github.com/react-hook-form/react-hook-form/issues/12816): Zod v4 beta versions throw
ZodError directly instead of capturing in formState.errors. Fixed in stable Zod v4.1.x+. Avoid beta versions19. **Large Form Performance** - [#13129](https://github.com/react-hook-form/react-hook-form/issues/13129): 300+ fields with resolver + formState read freezes for 10-15 seconds. See Performance section for 4 workarounds
20. **shadcn Form Import Confusion** - IDEs/AI may auto-import
Form from "react-hook-form" instead of shadcn. Always import Form components from @/components/ui/form---
## Upcoming Changes in V8 (Beta)
React Hook Form v8 (currently in beta as of v8.0.0-beta.1, released 2026-01-11) introduces breaking changes. [RFC Discussion #7433](https://github.com/orgs/react-hook-form/discussions/7433)
**Breaking Changes**:
1. **useFieldArray:
id → key**:// V7:
const { fields } = useFieldArray({ control, name: "items" });
fields.map(field => <div key={field.id}>...</div>)
// V8:
const { fields } = useFieldArray({ control, name: "items" });
fields.map(field => <div key={field.key}>...</div>)
// keyName prop removed2. **Watch component:
names → name**:// V7:
<Watch names={["email", "password"]} />
// V8:
<Watch name={["email", "password"]} />3. **watch() callback API removed**:
// V7:
watch((data, { name, type }) => {
console.log(data, name, type);
});
// V8: Use useWatch or manual subscription
const data = useWatch({ control });
useEffect(() => {
console.log(data);
}, [data]);4. **setValue() no longer updates useFieldArray**:
// V7:
setValue("items", newArray); // Updates field array
// V8: Must use replace() API
const { replace } = useFieldArray({ control, name: "items" });
replace(newArray);**V8 Benefits**:
- Fixes SSR hydration mismatch (deterministic
key instead of random id)- Improved performance
- Better TypeScript inference
**Migration Timeline**: V8 is in beta. Stable release date TBD. Monitor [releases](https://github.com/react-hook-form/react-hook-form/releases) for stable version.
---
## Bundled Resources
**Templates**: basic-form.tsx, advanced-form.tsx, shadcn-form.tsx, server-validation.ts, async-validation.tsx, dynamic-fields.tsx, multi-step-form.tsx, package.json
**References**: zod-schemas-guide.md, rhf-api-reference.md, error-handling.md, performance-optimization.md, shadcn-integration.md, top-errors.md
**Docs**: https://react-hook-form.com/ | https://zod.dev/ | https://ui.shadcn.com/docs/components/form
---
**License**: MIT | **Last Verified**: 2026-01-20 | **Skill Version**: 2.1.0 | **Changes**: Added 8 new known issues (Zod v4 optional fields bug, useFieldArray primitives limitation, SSR hydration mismatch, performance guidance for large forms, Next.js 16 reset() bug, validation race condition, ZodError thrown in beta, shadcn import confusion), added Zod v4.3.0 features (.exactOptional(), .xor(), z.fromJSONSchema()), added conditional field patterns with shouldUnregister, added V8 beta breaking changes section, expanded Zod v4 resolver compatibility notes, updated to [email protected]
---
---
paths: "**/*.tsx", "**/*form*.ts", "**/*.ts"
---
# React Hook Form + Zod Corrections
## ALWAYS Set defaultValues
/* ❌ Causes uncontrolled→controlled warning */
const { register } = useForm<FormData>({
resolver: zodResolver(schema),
// No defaultValues!
})
/* ✅ Always provide defaultValues */
const { register } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: '',
email: '',
age: undefined, // or appropriate default
},
})## useFieldArray: Use field.id as Key
/* ❌ Using index causes infinite re-renders */
{fields.map((field, index) => (
<input key={index} {...register(items.${index}.name)} />
))}
/* ✅ Use field.id */
{fields.map((field, index) => (
<input key={field.id} {...register(items.${index}.name)} />
))}## Controller: MUST Spread {...field}
/* ❌ Field doesn't update */
<Controller
name="status"
control={control}
render={({ field }) => (
<Select value={field.value} onChange={field.onChange} />
)}
/>
/* ✅ Spread all field props */
<Controller
name="status"
control={control}
render={({ field }) => (
<Select {...field} />
)}
/>## Nested Error Access
/* ❌ May crash on undefined */
errors.address.street.message
/* ✅ Use optional chaining */
errors.address?.street?.message## Server Validation Required
/* ❌ Security vulnerability - client only */
const onSubmit = (data) => {
await api.post('/users', data) // No server validation!
}
/* ✅ Validate on BOTH client and server */
// Client: useForm with zodResolver
// Server:
app.post('/users', async (c) => {
const result = schema.safeParse(await c.req.json())
if (!result.success) {
return c.json({ errors: result.error.flatten() }, 400)
}
})## Show Server Errors with setError
/* ✅ Display server validation errors */
const onSubmit = async (data) => {
const response = await api.post('/users', data)
if (!response.ok) {
const { errors } = await response.json()
// Set field-specific errors
Object.entries(errors).forEach(([field, message]) => {
setError(field, { message })
})
}
}## Zod Refinement: Include path
/* ❌ Error shows on form, not field */
const schema = z.object({
password: z.string(),
confirm: z.string(),
}).refine(d => d.password === d.confirm, {
message: "Passwords don't match",
})
/* ✅ Include path for field placement */
const schema = z.object({
password: z.string(),
confirm: z.string(),
}).refine(d => d.password === d.confirm, {
message: "Passwords don't match",
path: ['confirm'], // Error shows on confirm field
})## Quick Fixes
| If Claude suggests... | Use instead... |
|----------------------|----------------|
| No
defaultValues | Always provide defaultValues: { field: '' } ||
key={index} in useFieldArray | key={field.id} || Partial field props | Spread
{...field} ||
errors.a.b.message | errors.a?.b?.message || Client-only validation | Validate on server too |
| Missing refinement path | Add
path: ['fieldName'] |How to Use This Skill Unit
Option A: Project-Specific (Recommended)
- Click "Download" above
- In your project, create the directory:
.agent/skills/react-hook-form-zod/ - Save the file as
SKILL.md - The agent will automatically discover the skill based on its description.
Option B: Global Installation (All Agents)
Save the file to these locations to make it available across all projects:
- Claude Code:
~/.claude/skills/jezweb/claude-skills/react-hook-form-zod/SKILL.md - Cursor:
~/.cursor/skills/jezweb/claude-skills/react-hook-form-zod/SKILL.md - Antigravity:
~/.gemini/antigravity/skills/jezweb/claude-skills/react-hook-form-zod/SKILL.md
🚀 Install with CLI:npx skills add jezweb/claude-skills