Build type-safe validated forms using React Hook Form v7 and Zod v4. Single schema works on client and server with full TypeScript inference via z.infer. Use when building forms, multi-step wizards, or fixing uncontrolled warnings, resolver errors, useFieldArray issues.
/plugin marketplace add jezweb/claude-skills/plugin install jezweb-tooling-skills@jezweb/claude-skillsThis skill inherits all available tools. When active, it can use any tool Claude has access to.
README.mdreferences/accessibility.mdreferences/error-handling.mdreferences/links-to-official-docs.mdreferences/performance-optimization.mdreferences/rhf-api-reference.mdreferences/shadcn-integration.mdreferences/top-errors.mdreferences/zod-schemas-guide.mdrules/react-hook-form-zod.mdscripts/check-versions.shtemplates/advanced-form.tsxtemplates/async-validation.tsxtemplates/basic-form.tsxtemplates/custom-error-display.tsxtemplates/dynamic-fields.tsxtemplates/multi-step-form.tsxtemplates/package.jsontemplates/server-validation.tstemplates/shadcn-form.tsxStatus: Production Ready ✅ Last Verified: 2026-01-09 Latest Versions: react-hook-form@7.70.0, zod@4.3.5, @hookform/resolvers@5.2.2
npm install react-hook-form@7.70.0 zod@4.3.5 @hookform/resolvers@5.2.2
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())
useForm Options (validation modes):
mode: 'onSubmit' (default) - Best performancemode: 'onBlur' - Good balancemode: 'onChange' - Live feedback, more re-rendersshouldUnregister: 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
zodResolver connects Zod to React Hook Form, preserving type safety
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.
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 }))
}
}
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() }),
])
Note: shadcn/ui deprecated the Form component. Use the Field component for new implementations (check latest docs).
Legacy Form component:
<FormField control={form.control} name="username" render={({ field }) => (
<FormItem>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
register (uncontrolled) over Controller (controlled) for standard inputswatch('email') not watch() (isolates re-renders to specific fields)shouldUnregister: true for multi-step forms (clears data on unmount)✅ 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
Zod v4 Type Inference - #13109 (Closed 2025-11-01): Use z.infer<typeof schema>. Resolved in v7.66.x+.
Uncontrolled→Controlled Warning - Always set defaultValues for all fields
Nested Object Errors - Use optional chaining: errors.address?.street?.message
Array Field Re-renders - Use key={field.id} in useFieldArray (not index)
Async Validation Race Conditions - Debounce validation, cancel pending requests
Server Error Mapping - Use setError() to map server errors to fields
Default Values Not Applied - Set defaultValues in useForm options (not useState)
Controller Field Not Updating - Always spread {...field} in render function
useFieldArray Key Warnings - Use field.id as key (not index)
Schema Refinement Error Paths - Specify path in refinement: refine(..., { path: ['fieldName'] })
Transform vs Preprocess - Use transform for output, preprocess for input
Multiple Resolver Conflicts - Use single resolver (zodResolver), combine schemas if needed
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-09
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.