VayuUI

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-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

ConcernRule
ValidationPut every validation rule in Zod; no ad-hoc required checks or duplicate manual validation.
TypesInfer form type with z.infer<typeof schema>; do not handwrite duplicate form interfaces.
DefaultsProvide defaultValues for every field; use undefined intentionally for unselected selects/enums.
InputsUse register for native inputs; use Controller for custom selects, date pickers, toggles, radios, and Vayu UI inputs that do not expose native refs cleanly.
ErrorsDisplay formState.errors.<field>?.message for every user-editable field.
SubmitWrap with handleSubmit; map form data to exact API payload before calling mutate.
PendingUse mutation isPending to disable/guard inputs and submit to prevent duplicates.
WritesUse TanStack Query mutations for POST/PUT/PATCH; invalidate affected query keys after success.
FeedbackSuccess closes container if applicable, resets form, invalidates queries, and shows a specific toast. Error shows trusted server message or fallback toast.
Edit modePrefill 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

  1. Define schema with user-facing messages and explicit optional/default fields.
  2. Infer type: type FormData = z.infer<typeof formSchema>.
  3. Create useForm<FormData>({ resolver: zodResolver(formSchema), defaultValues }).
  4. Wire fields with register or Controller; render error messages and disabled state.
  5. Create/use mutation hook with consistent onSuccess/onError.
  6. Submit via handleSubmit(onSubmit); normalize and map payload explicitly.
  7. 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.

On this page