# Rhf Zod Form Validation

> 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. Use when this capability is needed.

- Skill: `tomevault-io/rhf-zod-form-validation` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add tomevault-io/rhf-zod-form-validation`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tomevault-io/rhf-zod-form-validation/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: tomevault-io (https://skillmd.com/u/tomevault-io)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tomevault-io/rhf-zod-form-validation

---


# 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

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

```ts
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>;
```

```ts
const form = useForm<FormData>({
  resolver: zodResolver(formSchema),
  defaultValues: {
    name: '',
    email: '',
    role: undefined,
    tags: [],
  },
});
```

```tsx
<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}
```

```ts
const onSubmit = (data: FormData) => {
  const payload = {
    name: data.name.trim(),
    email: data.email.trim().toLowerCase(),
    role: data.role,
  };

  mutate(isEdit ? { id: entityId, ...payload } : payload);
};
```

```ts
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.');
  },
});
```

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

```ts
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.

---
> Source: [Rugved1652/vayu-ui](https://github.com/Rugved1652/vayu-ui) — distributed by [TomeVault](https://tomevault.io).
<!-- tomevault:4.0:skill_md:2026-07-01 -->

