# Shadcn Impl Form Validation

> Use when building an end-to-end form workflow in shadcn ui that combines the Form composition primitives with react-hook-form and zod, when wiring the full pipeline from zod schema to typed submit handler to server-error display, when adding async validation (email availability, username taken, coupon check), when surfacing API errors back to a specific field via setError, when toggling a submit button between idle, submitting, success, and disabled states, when resetting a form after successful submit to clear stale state, when building a multi-step wizard that preserves form state across pages, or when wiring a file upload control into the react-hook-form value tree. Prevents the common end-to-end form failures: defining the zod schema inside the component so the resolver re-creates every render, mixing schema validation with manual setError on the same field so the two systems race and the user sees flickering error text, omitting form.handleSubmit on the form element so the browser submits natively and re

- Skill: `impertio-studio/shadcn-impl-form-validation` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add impertio-studio/shadcn-impl-form-validation`
- Raw SKILL.md: https://api.skillmd.com/api/skills/impertio-studio/shadcn-impl-form-validation/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: MIT
- Author: Impertio-Studio (https://skillmd.com/u/impertio-studio)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/impertio-studio/shadcn-impl-form-validation

---


# shadcn ui: end-to-end Form workflow (zod + react-hook-form)

This skill is the IMPLEMENTATION recipe that orchestrates the primitives covered in `shadcn-syntax-form`, `shadcn-syntax-field`, and `shadcn-syntax-selectors`. It teaches the five-step workflow from zod schema definition to typed submit handler to server-error display.

ALWAYS read `shadcn-syntax-form` first if the seven Form primitives are unclear. ALWAYS read `shadcn-syntax-selectors` first if binding Select, Checkbox, RadioGroup, or Switch to Controller is unclear.

## Quick Reference

### Canonical end-to-end form

```tsx
"use client"

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"

import { Button } from "@/components/ui/button"
import {
  Form, FormControl, FormDescription, FormField,
  FormItem, FormLabel, FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { toast } from "sonner"

const signInSchema = z.object({
  email: z.string().min(1, "Required.").email("Invalid email."),
  password: z.string().min(8, "At least 8 characters."),
})

type SignInValues = z.infer<typeof signInSchema>

export function SignInForm() {
  const form = useForm<SignInValues>({
    resolver: zodResolver(signInSchema),
    defaultValues: { email: "", password: "" },
  })

  async function onSubmit(values: SignInValues) {
    const res = await fetch("/api/sign-in", {
      method: "POST",
      body: JSON.stringify(values),
    })
    if (!res.ok) {
      const { fieldErrors } = await res.json()
      for (const [name, message] of Object.entries(fieldErrors ?? {})) {
        form.setError(name as keyof SignInValues, { type: "server", message: String(message) })
      }
      return
    }
    toast.success("Signed in.")
    form.reset()
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        <FormField control={form.control} name="email" render={({ field }) => (
          <FormItem>
            <FormLabel>Email</FormLabel>
            <FormControl><Input type="email" autoComplete="email" {...field} /></FormControl>
            <FormMessage />
          </FormItem>
        )} />
        <FormField control={form.control} name="password" render={({ field }) => (
          <FormItem>
            <FormLabel>Password</FormLabel>
            <FormControl><Input type="password" autoComplete="current-password" {...field} /></FormControl>
            <FormMessage />
          </FormItem>
        )} />
        <Button type="submit" disabled={form.formState.isSubmitting}>
          {form.formState.isSubmitting ? "Signing in..." : "Sign in"}
        </Button>
      </form>
    </Form>
  )
}
```

### Controller vs register decision matrix

| shadcn input | Path | Field binding pattern |
|--------------|------|------------------------|
| `Input`, `Textarea` | Controller (via FormField) | `<Input {...field} />` |
| `Select` | Controller | `<Select value={field.value} onValueChange={field.onChange}>` |
| `Checkbox` | Controller | `<Checkbox checked={field.value} onCheckedChange={field.onChange}>` |
| `RadioGroup` | Controller | `<RadioGroup value={field.value} onValueChange={field.onChange}>` |
| `Switch` | Controller | `<Switch checked={field.value} onCheckedChange={field.onChange}>` |
| `Slider` | Controller | `<Slider value={field.value} onValueChange={field.onChange}>` |
| `InputOTP` | Controller | `<InputOTP value={field.value} onChange={field.onChange}>` |
| Native `<input type="file">` | Controller (NEVER register) | `<input onChange={(e) => field.onChange(e.target.files)}>` |
| Native `<input type="text">` | register permitted | `<input {...form.register("name")}>` (loses aria wiring) |

NEVER bind a Radix-wrapped shadcn control with `register`. NEVER bind a file input with `register` because the FileList resets on every re-render.

## The five-step end-to-end workflow

### Step 1: define the zod schema (module scope)

ALWAYS define the schema OUTSIDE the component body and infer the TypeScript type via `z.infer`. NEVER inline the schema inside the component: the schema object would be re-created every render, `zodResolver(schema)` would also re-create, and react-hook-form's memoised resolver reference would invalidate every render.

```tsx
const signUpSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords do not match.",
  path: ["confirmPassword"],
})

type SignUpValues = z.infer<typeof signUpSchema>
```

ALWAYS use `.refine` (not `.superRefine`) for single-field or cross-field rules with a single message. ALWAYS set `path: [<field-name>]` so the error attaches to a specific field; without `path`, the error attaches to the form root and `<FormMessage />` will not render it.

### Step 2: instantiate useForm with zodResolver

ALWAYS pass `resolver: zodResolver(schema)` and explicit `defaultValues` for every field declared in the schema. ALWAYS pass the inferred type as the generic to `useForm<SignUpValues>` so `form.control`, `form.setValue`, and the `onSubmit` data argument are all type-safe.

```tsx
const form = useForm<SignUpValues>({
  resolver: zodResolver(signUpSchema),
  defaultValues: { email: "", password: "", confirmPassword: "" },
  mode: "onSubmit",     // when to validate: onSubmit | onBlur | onChange | onTouched | all
  reValidateMode: "onChange",
})
```

ALWAYS provide a default for every schema key (`""` for strings, `false` for booleans, `0` or null for numbers, `[]` for arrays, `null` for files). NEVER leave a default `undefined`: react-hook-form treats `undefined` as uncontrolled and React warns on the first keystroke.

### Step 3: compose Form primitives

ALWAYS spread `{...form}` on `<Form>`, ALWAYS pass `onSubmit={form.handleSubmit(onValid)}` to the inner `<form>` element, ALWAYS wrap every field in `<FormField control={form.control} name="<schema-key>" render={({ field }) => (...)}>`, ALWAYS render exactly one `<FormItem>` per field, and ALWAYS include `<FormMessage />` even when no description is present. See `shadcn-syntax-form` for the seven-primitive composition tree.

### Step 4: handle submit (onValid + onInvalid)

`form.handleSubmit(onValid, onInvalid?)` returns a single event-handler function. ALWAYS pass the RESULT of calling `handleSubmit`, never `handleSubmit` itself.

```tsx
async function onValid(values: SignUpValues) {
  // values are zod-parsed and type-safe.
  // ... call API, handle server errors, reset on success.
}

function onInvalid(errors: FieldErrors<SignUpValues>) {
  // optional: runs only when client-side validation fails.
}

<form onSubmit={form.handleSubmit(onValid, onInvalid)}>...</form>
```

While `onValid` returns a pending Promise, `form.formState.isSubmitting` is `true`. ALWAYS disable the submit button via `disabled={form.formState.isSubmitting}` so the user cannot double-submit. NEVER read `form.formState.isLoading` for this purpose: `isLoading` reflects async `defaultValues` loading, not submit progress.

### Step 5: error handling (server + reset)

ALWAYS map server-returned field errors back via `form.setError(fieldName, { type: "server", message })` AFTER the API call resolves. ALWAYS call `form.reset()` after a successful submit so the form clears stale data (unless the form is an edit-existing-record flow where the user expects to keep the values).

```tsx
async function onSubmit(values: SignUpValues) {
  const res = await fetch("/api/sign-up", { method: "POST", body: JSON.stringify(values) })
  if (!res.ok) {
    const { fieldErrors, formError } = await res.json()
    if (formError) {
      form.setError("root.serverError", { type: "server", message: formError })
    }
    for (const [name, message] of Object.entries(fieldErrors ?? {})) {
      form.setError(name as keyof SignUpValues, { type: "server", message: String(message) })
    }
    return
  }
  toast.success("Account created.")
  form.reset()
}
```

ALWAYS use `"root.<key>"` (e.g. `"root.serverError"`) for non-field-specific errors. The error is readable from `form.formState.errors.root?.serverError?.message` and survives until the next `form.clearErrors("root.serverError")` or `form.reset()`.

## Async validation pattern

zod supports async refinement via `.refine(async ...)` and `.superRefine(async ...)`. `form.formState.isValidating` is `true` while at least one async check is in flight. Use this for email availability, username taken, coupon code checks.

ALWAYS debounce the network call (300 to 500 ms) so each keystroke does not fire a request. ALWAYS handle the stale-response case by comparing the awaited value against the current schema input value (or by aborting prior requests via `AbortController`).

```tsx
const signUpSchema = z.object({
  email: z.string().email().refine(
    async (email) => {
      const res = await fetch(`/api/check-email?email=${encodeURIComponent(email)}`)
      const { taken } = await res.json()
      return !taken
    },
    { message: "Email already in use." }
  ),
})
```

ALWAYS `await` every async check inside `.refine`. NEVER fire-and-forget an async call from `.refine`: the resolver expects a boolean (or boolean Promise) and a stale resolution after the user has moved on can flicker the error state. See `references/examples.md` for the AbortController + debounce pattern.

## Server-side error mapping (setError)

Server validation errors that the client-side schema cannot catch (e.g. "email already taken", "invalid coupon code", "rate-limited") MUST be mapped back to specific fields via `form.setError`. ALWAYS use `type: "server"` so the error can be distinguished from client-side validation errors. ALWAYS clear server errors before re-submitting (`form.clearErrors()` runs implicitly via `handleSubmit`, but explicit `clearErrors` is safer for partial resubmits).

```tsx
// Server returns: { fieldErrors: { email: "Email already taken." }, formError: null }
for (const [name, message] of Object.entries(response.fieldErrors)) {
  form.setError(name as keyof FormValues, { type: "server", message: String(message) })
}
```

NEVER mix client-side zod validation and manual `setError` on the same field within the same render cycle: the next user keystroke triggers a re-validation that wipes the manual error. ALWAYS rely on the zod schema for everything the client can check and use `setError` ONLY for errors the server discovers.

## Loading state on submit button

ALWAYS read `form.formState.isSubmitting` (not `isLoading`, not `isValidating`) to drive the submit button's disabled state.

```tsx
<Button type="submit" disabled={form.formState.isSubmitting || !form.formState.isValid}>
  {form.formState.isSubmitting ? (
    <><Loader2 className="size-4 animate-spin" /> Saving...</>
  ) : "Save"}
</Button>
```

| Flag | Meaning |
|------|---------|
| `isSubmitting` | `onValid` returned a Promise that is still pending. |
| `isValidating` | A zod async `.refine` (or any async validator) is running. |
| `isLoading` | Async `defaultValues` (a Promise passed to `useForm`'s `defaultValues`) is still loading. |
| `isSubmitSuccessful` | The last `onValid` call resolved without throwing. |
| `isValid` | The form passes the resolver. |

ALWAYS gate the submit button on `isSubmitting` alone unless the schema is cheap to validate; gating on `!isValid` while async validation is in flight visibly flickers the button.

## File upload pattern

File inputs MUST be bound via Controller (not register) because react-hook-form copies the FileList by reference and a re-render can wipe the value. ALWAYS store the `FileList` in form state; validate via `.refine` for file count, size, and MIME type.

```tsx
const schema = z.object({
  resume: z.instanceof(FileList)
    .refine((files) => files.length === 1, "Upload one file.")
    .refine((files) => files[0]?.size <= 5 * 1024 * 1024, "Max 5 MB.")
    .refine((files) => ["application/pdf"].includes(files[0]?.type), "PDF only."),
})
```

```tsx
<FormField control={form.control} name="resume" render={({ field: { onChange, value, ...rest } }) => (
  <FormItem>
    <FormLabel>Resume</FormLabel>
    <FormControl>
      <Input type="file" accept="application/pdf" {...rest}
        onChange={(e) => onChange(e.target.files)} />
    </FormControl>
    <FormMessage />
  </FormItem>
)} />
```

ALWAYS destructure `value` away from the spread (a file input cannot have a controlled `value` prop in React). ALWAYS forward `e.target.files` (FileList), not `e.target.files[0]` (single File). For multipart upload to the server, build a `FormData` object in `onSubmit`.

## Multi-step wizard pattern

Preserve form state across pages by wrapping every step in a single `<FormProvider>` (the same context that `<Form>` provides) and reading via `useFormContext` in each step component. The wizard renders ONE step at a time but the form state is shared.

```tsx
import { FormProvider, useFormContext, useForm } from "react-hook-form"

const wizardSchema = z.object({
  step1: z.object({ name: z.string().min(1), email: z.string().email() }),
  step2: z.object({ address: z.string().min(1), city: z.string().min(1) }),
  step3: z.object({ agree: z.literal(true) }),
})
type WizardValues = z.infer<typeof wizardSchema>

export function Wizard() {
  const form = useForm<WizardValues>({
    resolver: zodResolver(wizardSchema),
    defaultValues: { step1: { name: "", email: "" }, step2: { address: "", city: "" }, step3: { agree: false } },
    mode: "onChange",
  })
  const [step, setStep] = useState(0)
  const steps = [Step1, Step2, Step3]
  const StepComponent = steps[step]

  async function next() {
    const stepKey = `step${step + 1}` as keyof WizardValues
    const ok = await form.trigger(stepKey)
    if (ok) setStep((s) => s + 1)
  }

  return (
    <FormProvider {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <StepComponent />
        {step < steps.length - 1 ? (
          <Button type="button" onClick={next}>Next</Button>
        ) : (
          <Button type="submit" disabled={form.formState.isSubmitting}>Submit</Button>
        )}
      </form>
    </FormProvider>
  )
}

function Step1() {
  const form = useFormContext<WizardValues>()
  return <FormField control={form.control} name="step1.name" render={...} />
}
```

ALWAYS call `form.trigger(stepKey)` before advancing to validate only the current step's fields. NEVER unmount form state between steps: react-hook-form preserves values across renders, but unmounting a `<FormProvider>` clears state. The `FormProvider` MUST be a stable ancestor of every step.

## Companion Skills

- [shadcn-syntax-form](../../shadcn-syntax/shadcn-syntax-form/SKILL.md) (Batch 3): the seven Form primitives and their composition tree.
- [shadcn-syntax-field](../../shadcn-syntax/shadcn-syntax-field/SKILL.md) (Batch 4): the new 2026 Field primitive family that decouples a11y wiring from react-hook-form.
- [shadcn-syntax-selectors](../../shadcn-syntax/shadcn-syntax-selectors/SKILL.md) (Batch 5): binding Select, Combobox, Command palette via Controller.
- [shadcn-errors-form-state](../../shadcn-errors/shadcn-errors-form-state/SKILL.md) (Batch 12): top failure modes (Controller vs register confusion, watch over-rendering, async stale state).
- [shadcn-syntax-button](../../shadcn-syntax/shadcn-syntax-button/SKILL.md): submit button variants and `disabled={form.formState.isSubmitting}` pattern.

## Reference files

- [references/methods.md](references/methods.md): full API signatures (useForm options, zodResolver, handleSubmit, setError, clearErrors, reset, trigger, formState fields, Controller props, common zod async patterns).
- [references/examples.md](references/examples.md): minimal sign-in, sign-up with confirm-password, async email-availability with AbortController, server-error setError, file upload with FileList, multi-step wizard with FormProvider, isSubmitting submit button.
- [references/anti-patterns.md](references/anti-patterns.md): six canonical end-to-end anti-patterns with WHY each fails and the fix.

## Sources

All claims trace to URLs in `SOURCES.md`:

- https://ui.shadcn.com/docs/components/radix/form (Form composition canonical)
- https://ui.shadcn.com/docs/forms/react-hook-form (current Field-based react-hook-form pattern)
- https://react-hook-form.com/docs/useform (useForm signature, formState fields)
- https://react-hook-form.com/docs/useform/seterror (setError contract)
- https://react-hook-form.com/docs/useform/handlesubmit (handleSubmit contract)
- https://react-hook-form.com/docs/useform/reset (reset contract)
- https://react-hook-form.com/docs/useformcontext (multi-step wizard pattern)
- https://zod.dev (zod schema basics)
- https://zod.dev/?id=refine (refine + async refine)
- https://github.com/react-hook-form/resolvers (zodResolver import path)

Verified 2026-05-19.

