SuperAPI AI Directory

shadcn-syntax-form

Frontend & websites

View repository

Updated 10-15 22:18

About this skill

shadcn ui : Form Composition (react-hook-form + zod)

The shadcn Form layer is a thin set of seven composition primitives wrapping react-hook-form and wiring accessibility attributes automatically. Every claim in this skill traces to the canonical source at apps/v4/registry/new-york-v4/ui/form.tsx in the shadcn-ui/ui repository and to the verbatim API of react-hook-form and zod.

ALWAYS read shadcn-core-architecture first if the copy-not-install paradigm is unclear. ALWAYS read shadcn-syntax-variant-cva if cn() and the variant model are unclear.

Quick Reference

Minimal form snippet

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

const formSchema = z.object({ username: z.string().min(2, "At least 2 characters.").max(50), })

type FormValues = z.infer

export function ProfileForm() { const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { username: "" }, })

function onSubmit(values: FormValues) { console.log(values) }

return ( <Form {...form}> <FormField control={form.control} name="username" render={({ field }) => ( Username <Input placeholder="shadcn" {...field} /> Your public display name. )} /> Submit ) }


### Seven-primitive map

| Primitive | Role | a11y wiring |
|-----------|------|-------------|
| `Form` | Re-export of `FormProvider` from react-hook-form. Spreads the `useForm` return value as context. | Provides form context; required ancestor of every `FormField`. |
| `FormField` | Wraps react-hook-form's `Controller`. Adds a context that publishes `name` to the descendants. | Bridges schema field name to rendered control. |
| `FormItem` | Grid layout container. Creates a unique `React.useId()` and publishes it as `formItemId`, `formDescriptionId`, `formMessageId`. | Ties label / control / description / message together via shared id. |
| `FormLabel` | Wraps shadcn `Label`. Reads `formItemId` from context and sets `htmlFor`. Adds `data-error` when the field has an error. | `htmlFor` -> control id; `data-error` enables destructive colour token. |
| `FormControl` | Renders Radix `Slot.Root`. Forwards props to its single child (the actual input). | Sets `id`, `aria-describedby` (description + message ids when error, description-only otherwise), `aria-invalid` (`true` when error). |
| `FormDescription` | Helper-text `<p>` with the description id. | Linked via `aria-describedby` on the control. |
| `FormMessage` | Error-text `<p>` with the message id. Renders the current zod error message when the field has one. | Linked via `aria-describedby` on the control only when error is present. |

ALWAYS use all seven primitives together. NEVER omit `FormMessage` : it is the only primitive that renders the zod error.

## The seven Form primitives

ALWAYS compose primitives in this exact tree :

Why each primitive exists

  1. Form : FormProvider re-exported under a shorter name. Spreading {...form} publishes control, register, handleSubmit, formState, setValue, reset, watch, getValues, and trigger to every descendant via React context. NEVER omit the spread : FormField calls useFormContext and throws when context is missing.
  2. FormField : a generic React component that internally renders react-hook-form's Controller and wraps it in a FormFieldContext.Provider that publishes name. The render prop receives { field, fieldState, formState } from Controller. ALWAYS pass control={form.control} and name="<schema-key>". The name MUST match a key in the zod schema; mismatches are runtime-silent.
  3. FormItem : a <div className="grid gap-2"> that calls React.useId() once per item and publishes id, formItemId, formDescriptionId, formMessageId via FormItemContext. ALWAYS render exactly one FormItem per FormField. NEVER share an id across fields by reusing a single FormItem.
  4. FormLabel : wraps shadcn Label. Internally calls useFormField() to read formItemId (sets htmlFor) and error (sets data-error="true", which the class string data-[error=true]:text-destructive colours red).
  5. FormControl : renders Radix Slot.Root. It takes exactly ONE child and forwards id={formItemId}, aria-describedby={!error ? formDescriptionId : formDescriptionId + " " + formMessageId}, and aria-invalid={!!error} to that child. ALWAYS pass a single React element child. NEVER nest two siblings under FormControl : Slot throws.
  6. FormDescription : renders <p id={formDescriptionId}>. The id is consumed by FormControl's aria-describedby. ALWAYS include a description when the field needs explanation; the wiring is automatic.
  7. FormMessage : renders <p id={formMessageId}>{error?.message}</p>. Returns null when there is no error AND no children. ALWAYS include <FormMessage /> even when no description is present; otherwise the zod error never renders.

ALWAYS leave the body of FormMessage empty (<FormMessage />); the component reads error?.message itself. NEVER hand-roll an error <p> outside the primitive : aria-describedby will not wire to it.

Schema-first pattern

ALWAYS define the zod schema OUTSIDE the component, derive the TypeScript type via z.infer, and pass both to useForm. NEVER inline the schema inside the component body : the schema object would be re-created on every render, zodResolver(schema) would also re-create, and react-hook-form's memoised resolver reference would invalidate every render. See references/anti-patterns.md §3 for the failure mode.

// Module-scope (good : created once)
const formSchema = z.object({
  email: z.string().min(1, "Required.").email("Invalid email."),
  age: z.coerce.number().int().min(18, "Must be 18+."),
  agree: z.boolean().refine((v) => v === true, "You must agree."),
})

type FormValues = z.infer<typeof formSchema>

export function MyForm() {
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: { email: "", age: 18, agree: false },
  })
  // ...
}

The <FormValues> generic on useForm propagates through form.control, field.value, the onSubmit data argument, and form.setValue's name+value pair. ALWAYS pass the generic explicitly; TypeScript will refuse mismatched name="" strings at compile time.

Controller vs register decision

ALWAYS use FormField (which uses Controller internally) for any control that does NOT accept a native ref. Native HTML inputs (<input>, <textarea>, <select>) accept ref and can be wired with form.register("name"). Radix-wrapped shadcn controls do NOT accept native ref : they use controlled value + onValueChange (or checked + onCheckedChange). Using register on these returns nothing useful and the value never enters form-state.

Control Path Why
shadcn Input <FormControl><Input {...field} /></FormControl> via FormField native <input> under the hood but spreading field covers everything
shadcn Textarea <FormControl><Textarea {...field} /></FormControl> native <textarea>
shadcn Select FormField + render with Select value={field.value} onValueChange={field.onChange} controlled, no native ref
shadcn Checkbox FormField + render with Checkbox checked={field.value} onCheckedChange={field.onChange} controlled, no native ref
shadcn RadioGroup FormField + render with RadioGroup value={field.value} onValueChange={field.onChange} controlled, no native ref
shadcn Switch FormField + render with Switch checked={field.value} onCheckedChange={field.onChange} controlled, no native ref
shadcn Slider FormField + render with Slider value={field.value} onValueChange={field.onChange} controlled, no native ref
Native <input> register("name") is permitted, but loses auto-wired aria-describedby, aria-invalid, and data-error bypasses FormControl wiring

NEVER use register for Select, Checkbox, RadioGroup, Switch, Slider, Toggle, ToggleGroup, or any other Radix-wrapped control. NEVER use register for components shipped as controlled-only.

defaultValues vs values vs reset

Option Behaviour Use when
defaultValues Set ONCE at mount. Internal form-state initialised to this object. Changing the prop later has NO effect. Static initial values known at render time.
values CONTROLLED. Whenever the prop changes, the form re-initialises to the new object (preserves dirty fields per resetOptions). Editing an existing record where data arrives async (after fetch).
form.reset(newValues, options?) Imperative reset from inside an effect or callback. After successful submit, after user clicks "Cancel", after async data lands.

ALWAYS provide defaultValues for every field declared in the schema. NEVER leave a field absent or undefined : react-hook-form treats undefined as uncontrolled and React will warn "A component is changing an uncontrolled input to be controlled" on the first keystroke. For booleans use false; for strings use ""; for numbers use 0 or a sensible sentinel; for arrays use [].

ALWAYS use values (not defaultValues) when the initial data arrives after first render (e.g. useQuery for editing a record). Mixing the two is supported but values wins on prop change.

// Pattern : editing an existing record
const { data } = useQuery({ queryKey: ["user", id], queryFn: getUser })
const form = useForm<FormValues>({
  resolver: zodResolver(formSchema),
  values: data,                              // re-resets when data arrives
  defaultValues: { name: "", email: "" },    // fallback before data loads
})

Submit handling

form.handleSubmit(onValid, onInvalid?) returns a single event-handler function suitable for the native <form onSubmit={...}> prop. ALWAYS pass the RESULT of calling handleSubmit, never handleSubmit itself.

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

function onValid(values: FormValues) {
  // values are already zod-parsed and type-safe
}

function onInvalid(errors: FieldErrors<FormValues>) {
  // optional : runs only when validation fails
  console.warn(errors)
}

handleSubmit :

  1. Prevents the native form submit (calls event.preventDefault() internally).
  2. Runs zodResolver (or any other resolver) against the current values.
  3. On success, calls onValid(values, event).
  4. On failure, calls onInvalid(errors, event) if provided; updates formState.errors either way.
  5. Sets formState.isSubmitting to true while onValid returns a Promise; back to false on settle.

ALWAYS check form.formState.isSubmitting to disable the submit button during async work. NEVER call onValid outside handleSubmit (e.g. <Button onClick={() => onValid(form.getValues())}>) : validation will not run.

Async validation

zod supports async refinement via .refine(async (v) => ...) and .superRefine. react-hook-form reflects pending status via formState.isValidating. Examples : checking whether an email is already taken, validating a coupon code against an API.

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

ALWAYS await every async check inside the refine callback. NEVER fire-and-forget an async call from refine : the resolver expects a boolean (or boolean Promise) return; a stale fetch resolves after the user has moved on. See references/examples.md for a debounced async-validation pattern.

RSC and 'use client'

The shadcn Form component uses React hooks (useId, useContext, useFormContext, useFormState). ALWAYS mark every file that imports from @/components/ui/form with "use client" at the top, even when wrapping the form in a server component shell. Without the directive Next.js (App Router) and any other RSC framework throws "useContext is not a function" on the server.

ALWAYS render the server-component shell as the form's PARENT and keep the entire form in a single client component. NEVER split <Form> and <FormField> across a server-client boundary : the React context cannot cross it.

Forward-pointer : the Field primitive (2026)

shadcn ui 2026 introduces a separate Field family (Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldSet, FieldLegend, FieldContent, FieldSeparator, FieldTitle) that provides the same accessibility wiring without coupling to react-hook-form. The official docs now show the canonical react-hook-form pattern composing raw Controller with Field primitives. The Form composition documented in this skill remains in the registry and is fully supported; Field is the recommended new path for new code AND for TanStack Form integrations.

ALWAYS read shadcn-syntax-field (Batch 4) when starting a new project. For existing projects that already use Form / FormField / FormItem, this skill remains the canonical reference; the two systems work side by side.

Companion Skills

Reference files

  • references/methods.md : complete API signatures (Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage, useFormField, useForm options, Controller props, zodResolver, common zod schema patterns).
  • references/examples.md : sign-in form, Select + Controller, Checkbox + Controller, native Input via register, async refine, defaultValues vs values, custom FormField composition.
  • references/anti-patterns.md : seven canonical anti-patterns with WHY each fails and the fix.

Sources

All claims trace to URLs in SOURCES.md :

Verified 2026-05-19.