Loading...
Loading...
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, performance problems with large forms.
npx skill4agent add jezweb/claude-skills react-hook-form-zodnpm install react-hook-form@7.70.0 zod@4.3.5 @hookform/resolvers@5.2.2const 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>// SAME schema on server
const data = schema.parse(await req.json())mode: 'onSubmit'mode: 'onBlur'mode: 'onChange'shouldUnregister: truez.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
})z.string().transform((val) => val.toLowerCase()) // Data manipulation
z.string().transform(parseInt).refine((v) => v > 0) // Chain with refine// 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" } } })<input {...register('email')} /> // Uncontrolled, best performance<Controller
name="category"
control={control}
render={({ field }) => <CustomSelect {...field} />} // MUST spread {...field}
/>register{errors.email && <span role="alert">{errors.email.message}</span>}
{errors.address?.street?.message} // Nested errors (use optional chaining)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 }))
}
}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>const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)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)
}z.discriminatedUnion('accountType', [
z.object({ accountType: z.literal('personal'), name: z.string() }),
z.object({ accountType: z.literal('business'), companyName: z.string() }),
])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"],
})Form// ✅ 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";<FormField control={form.control} name="username" render={({ field }) => (
<FormItem>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />registerControllerwatch('email')watch()shouldUnregister: trueformState// ❌ Slow with 300+ fields:
const { isDirty, isValid } = form.formState;
// ✅ Fast:
const handleSubmit = () => {
if (!form.formState.isValid) return; // Read inline only when needed
};const form = useForm({
resolver: zodResolver(largeSchema),
mode: "onSubmit", // Validate only on submit, not onChange
});// 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-100// Only mount fields for active tab, reduces initial registration time
{activeTab === 'personal' && <PersonalInfoFields />}
{activeTab === 'address' && <AddressFields />}field.id{...field}z.infer<typeof schema>setValue()defaultValueserrors.address?.street?.messagekey={field.id}setError()defaultValues{...field}field.idpathrefine(..., { path: ['fieldName'] })transformpreprocess.optional()"".nullish().or(z.literal(""))z.preprocess((val) => val === "" ? undefined : val, z.email().optional())useFieldArraystring[][{ value: "string" }]["string"]keyform.reset()setValue()reset()isValidating=falseerrors!errors.field && !isValidatingZodErrorformState.errorsFormForm@/components/ui/formidkey// 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 removednamesname// V7:
<Watch names={["email", "password"]} />
// V8:
<Watch name={["email", "password"]} />// 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]);// V7:
setValue("items", newArray); // Updates field array
// V8: Must use replace() API
const { replace } = useFieldArray({ control, name: "items" });
replace(newArray);keyid