Overview

The current shadcn docs build forms from the Field family (Field, FieldGroup, FieldLabel, FieldDescription, FieldError), which handles layout and error display and works with any form library. The documented React Hook Form pattern wraps each control in Controller. Older projects use Form, FormField, and FormMessage; those keep working, but new forms start from Field.

Define the schema first

The schema is the source of truth for field names and messages. Any Standard Schema library works with React Hook Form; the docs use Zod. Put it in its own file when a server action validates the same data, so both sides share one definition.

import * as z from "zod";
 
export const formSchema = z.object({
  title: z.string().min(5, "Title must be at least 5 characters.").max(32),
  description: z.string().min(20, "Describe the bug in at least 20 characters.").max(100),
});

Create the form with zodResolver and full defaults

Set defaultValues for every field; a missing default produces uncontrolled-to-controlled warnings.

const form = useForm<z.infer<typeof formSchema>>({
  resolver: zodResolver(formSchema),
  defaultValues: { title: "", description: "" },
});

Validation runs on submit by default; mode: "onTouched" validates on first blur and then on every change.

Wrap each control in Controller and Field

<form id="bug-form" onSubmit={form.handleSubmit(onSubmit)}>
  <FieldGroup>
    <Controller
      name="title"
      control={form.control}
      render={({ field, fieldState }) => (
        <Field data-invalid={fieldState.invalid}>
          <FieldLabel htmlFor={field.name}>Title</FieldLabel>
          <Input {...field} id={field.name} aria-invalid={fieldState.invalid} autoComplete="off" />
          <FieldDescription>A short summary.</FieldDescription>
          {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
        </Field>
      )}
    />
  </FieldGroup>
</form>

Set data-invalid on Field for the error styling and aria-invalid on the control for assistive tech. FieldError renders role="alert", so screen readers announce it; do not add a second error paragraph. A submit button outside the <form> uses form="bug-form".

Map composite controls to field

Select, Checkbox, RadioGroup, and Switch do not take a spread field; map the change handler and value.

<Select name={field.name} value={field.value} onValueChange={field.onChange}>
  <SelectTrigger id={field.name} aria-invalid={fieldState.invalid}>
    <SelectValue placeholder="Select" />
  </SelectTrigger>
  {/* SelectContent ... */}
</Select>

Checkbox and Switch use checked={field.value} and onCheckedChange={field.onChange}. Put aria-invalid on the trigger or control, not the wrapper.

Surface server errors with setError

When a server action returns a field-level failure, inject it into the same display path.

form.setError("email", { type: "server", message: result.error.message });
form.setError("root", { type: "server", message: "Network error. Try again." });

Read root errors from form.formState.errors.root and render them above the submit button.

Use useActionState for Server Actions

For Next.js without client-side form state, render a <Form action={formAction}> (from next/form), validate with the shared schema in the action, and return { values, errors, success }. Return values on failure so the inputs keep what the user typed. Wire FieldError from the returned errors and aria-invalid from their presence. The server check stays authoritative; see nextjs-server-actions and react-forms.