RHF + Zod Validation Patterns
Enforce React Hook Form + Zod + TanStack Query mutation patterns for validated forms.
React Hook Form + Zod Validation
This skill defines the canonical pattern for building validated, mutation-backed forms. It combines React Hook Form, Zod schemas, and TanStack Query mutations into a single repeatable workflow that makes every form predictable and easy to review.
npx skills add Rugved1652/vayu-ui/react-hook-form-zod-validationUse this pattern for every validated form. The schema owns validation, z.infer owns form types, react-hook-form owns form state, and TanStack Query owns writes.
Hard Rules
| Concern | Rule |
|---|---|
| Validation | Put every validation rule in Zod; no ad-hoc required checks or duplicate manual validation. |
| Types | Infer form type with z.infer<typeof schema>; do not handwrite duplicate form interfaces. |
| Defaults | Provide defaultValues for every field; use undefined intentionally for unselected selects/enums. |
| Inputs | Use register for native inputs; use Controller for custom selects, date pickers, toggles, radios, and Vayu UI inputs that do not expose native refs cleanly. |
| Errors | Display formState.errors.<field>?.message for every user-editable field. |
| Submit | Wrap with handleSubmit; map form data to exact API payload before calling mutate. |
| Pending | Use mutation isPending to disable/guard inputs and submit to prevent duplicates. |
| Writes | Use TanStack Query mutations for POST/PUT/PATCH; invalidate affected query keys after success. |
| Feedback | Success closes container if applicable, resets form, invalidates queries, and shows a specific toast. Error shows trusted server message or fallback toast. |
| Edit mode | Prefill with reset() in a guarded effect keyed by open/data; use query enabled for state-dependent fetches. |
File Placement
- Form UI:
containers/Forms/<Name>Form.tsx - Zod schema:
utils/validations/<feature>Schema.ts - API request/response types:
types/api-types/ - Mutation hook:
api/hooks/use<Operation>.ts - API call:
api/services/<feature>Service.ts - HTTP client:
api/api.ts(axios singleton with interceptors)
Follow folder-structure, api-call-tanstack-query, code-quality, and design-system.
Build Recipe
- Define schema with user-facing messages and explicit optional/default fields.
- Infer type:
type FormData = z.infer<typeof formSchema>. - Create
useForm<FormData>({ resolver: zodResolver(formSchema), defaultValues }). - Wire fields with
registerorController; render error messages and disabled state. - Create/use mutation hook with consistent
onSuccess/onError. - Submit via
handleSubmit(onSubmit); normalize and map payload explicitly. - For edit forms,
reset(prefillValues)only when open/data is ready.
Canonical Snippets
const formSchema = z.object({
name: z.string({ message: 'Name is required.' }).min(1, { message: 'Name is required.' }),
email: z.string({ message: 'Email is required.' }).email({ message: 'Enter a valid email.' }),
role: z.nativeEnum(Role, { message: 'Please select a role.' }),
tags: z.array(z.string().min(1, { message: 'Tag cannot be empty.' })).min(1, {
message: 'Add at least one tag.',
}),
});
type FormData = z.infer<typeof formSchema>;const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
email: '',
role: undefined,
tags: [],
},
});<Controller
name="role"
control={form.control}
render={({ field }) => (
<SelectComponent
value={field.value}
onValueChange={field.onChange}
onBlur={field.onBlur}
disabled={isPending}
/>
)}
/>
{form.formState.errors.role?.message ? (
<p role="alert">{form.formState.errors.role.message}</p>
) : null}const onSubmit = (data: FormData) => {
const payload = {
name: data.name.trim(),
email: data.email.trim().toLowerCase(),
role: data.role,
};
mutate(isEdit ? { id: entityId, ...payload } : payload);
};const mutation = useCreateEntity({
onSuccess: async () => {
onClose?.();
form.reset();
await queryClient.invalidateQueries({ queryKey: ['entities'] });
toast.success('Entity created successfully.');
},
onError: (error) => {
toast.error(error instanceof Error && error.message ? error.message : 'Please try again.');
},
});useEffect(() => {
if (open && initialData) {
form.reset({
name: initialData.name,
email: initialData.email,
role: initialData.role,
});
}
}, [open, initialData, form]);
const entityQuery = useEntityById(entityId, {
enabled: open && !!entityId,
});Advanced Validation
const schema = z
.object({
type: z.enum(['email', 'sms']),
phoneNumber: z.string().optional(),
})
.refine((data) => data.type !== 'sms' || !!data.phoneNumber?.trim(), {
message: 'Phone number is required for SMS.',
path: ['phoneNumber'],
});Use .optional(), .nullable(), .default(), arrays, enums, and .refine() intentionally so the schema mirrors product behavior.
Review Checklist
- Schema is the single validation source and required fields have user-facing messages.
- Form type uses
z.infer;zodResolveris wired. - Every field has explicit default value and visible error rendering.
- Custom inputs use
Controller; native inputs useregister. - Submit uses
handleSubmit, maps/normalizes payload, and never passes raw form data unless contracts are identical. isPendingdisables duplicate submission paths.- Success closes, resets, invalidates relevant queries, and shows specific toast.
- Error uses trusted server message or fallback toast.
- Edit mode uses guarded
reset()and state-dependent queries useenabled.
Anti-Patterns
- Formik/final-form, manual validation, or validation hidden in submit handlers.
- Handwritten form types duplicating Zod schema.
- Missing
defaultValues, hidden errors, or uncontrolled/controlled warnings. - Passing raw form data directly to API without payload mapping.
- Forgetting invalidation after writes.
- Unguarded edit-mode
reset()that wipes user input.