Loading...
Loading...
Type-safe React forms with React Hook Form and Zod validation. Use for form schemas, field arrays, multi-step forms, or encountering validation errors, resolver issues, nested field problems.
npx skill4agent add secondsky/claude-skills react-hook-form-zodbun add react-hook-form@7.66.1 zod@4.1.12 @hookform/resolvers@5.2.2import { 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>
)
}defaultValueszodResolver(schema)z.infer<typeof schema>templates/basic-form.tsx// 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
}
}templates/server-validation.tsconst {
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)
})onSubmitonChangeonBlurallreferences/rhf-api-reference.mdimport { 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
})references/zod-schemas-guide.mddefaultValueszodResolverz.infer<typeof schema>.register()Controllerrole="alert"reset()templates/basic-form.tsxadvanced-form.tsxshadcn-form.tsxmulti-step-form.tsxasync-validation.tsxdefaultValuessetValue()isSubmittingreferences/performance-optimization.mdmode: 'onBlur''onChange'useWatchwatch()references/accessibility.mdWarning: A component is changing an uncontrolled input to be controlleddefaultValues// ❌ BAD
const form = useForm()
// ✅ GOOD
const form = useForm({
defaultValues: {
email: '',
password: '',
}
})// Explicitly type useForm if needed
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
})Module not found: Can't resolve '@hookform/resolvers/zod'# Install the resolvers package
bun add @hookform/resolvers@5.2.2useFieldArrayconst { fields, append, remove } = useFieldArray({
control,
name: "items" // Must match schema field name exactly
})templates/dynamic-fields.tsxControllerregister<Controller
name="date"
control={control}
render={({ field }) => (
<DatePicker {...field} />
)}
/>references/error-handling.mdreferences/top-errors.mdimport { 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>
)
}templates/basic-form.tsximport { 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>
)
}templates/dynamic-fields.tsxconst 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')
})templates/async-validation.tsxfunction 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>
)
}templates/multi-step-form.tsximport { 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>
)
}references/shadcn-integration.mdtemplates/shadcn-form.tsx| Reference | Load When... |
|---|---|
| Debugging validation issues, type errors, or "uncontrolled to controlled" warnings |
| Need complete API for useForm, register, Controller, formState |
| Building complex schemas (nested, arrays, conditional, async validation) |
| Using shadcn/ui Form, FormField, FormItem components |
| Custom error display, validation timing, error message patterns |
| Form re-renders too much, optimizing watch/useWatch |
| WCAG compliance, screen readers, keyboard navigation |
| Need official documentation links |
mode: 'onBlur'useWatchwatch()shouldUnregister: falsewatch()references/performance-optimization.md<label htmlFor="fieldId">role="alert"aria-invalid="true"references/accessibility.md// 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'
)references/zod-schemas-guide.mdreact-hook-form@7.65.0zod@4.1.12@hookform/resolvers@5.2.2@radix-ui/react-label@latestclass-variance-authority@latestreferences/links-to-official-docs.mddefaultValuesreferences/top-errors.mduseForm<z.infer<typeof schema>>references/top-errors.md@hookform/resolversreferences/top-errors.mdControllerregisterreferences/top-errors.mdmode: 'onBlur'useWatchreferences/performance-optimization.md