| name | rhf-zod-form-validation |
| description | Enforce React Hook Form + Zod + TanStack Query mutation patterns. Use when building or refactoring forms, modals, drawers, dialogs, pages with form state, Zod schemas, zodResolver wiring, submit handlers, POST/PUT/PATCH mutations, API payload mapping, query invalidation, toast feedback, server errors, edit-mode prefill, or enabled edit queries. Do not use for simple form layout-only questions without validation or submission logic. |
React Hook Form + Zod Validation
Use 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
register or Controller; 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; zodResolver is wired.
- Every field has explicit default value and visible error rendering.
- Custom inputs use
Controller; native inputs use register.
- Submit uses
handleSubmit, maps/normalizes payload, and never passes raw form data unless contracts are identical.
isPending disables 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 use enabled.
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.