---
title: "shadcn/ui: Forms"
slug: "shadcn-forms"
category: "frontend"
tags: ["frontend", "shadcn", "forms", "react-hook-form", "zod", "validation", "accessibility"]
status: "stable"
last_updated: 2026-05-14
summary: "Wire shadcn Form with useForm and zodResolver; use FormField for every input; surface errors through FormMessage."
related: ["[[frontend/shadcn]]", "[[frontend/shadcn-composition]]", "[[frontend/react-forms]]", "[[frontend/forms]]", "[[frontend/react]]", "[[frontend/nextjs-server-actions]]", "[[coding/typescript]]"]
---

## Overview

The shadcn `Form` component is a thin wrapper around React Hook Form and Zod. It provides controlled, accessible form fields with a consistent error-display contract. The pattern is: define a Zod schema, create a form with `useForm` and `zodResolver`, wrap each input in a `FormField`, and let `FormMessage` surface errors automatically. This page covers that pattern and the rules that keep forms correct and accessible.

## Define the Zod schema before writing the form

Write the schema first. It is the source of truth for field names, types, and validation rules. The TypeScript type inferred from it flows into `useForm`, which makes field names and error shapes fully typed.

```ts
import { z } from "zod";

const profileSchema = z.object({
  displayName: z.string().min(2, "Name must be at least 2 characters").max(50),
  email: z.string().email("Enter a valid email address"),
  bio: z.string().max(300, "Bio cannot exceed 300 characters").optional(),
});

type ProfileFormValues = z.infer<typeof profileSchema>;
```

Export the schema from a separate file (e.g., `lib/schemas/profile.ts`) if the same schema validates a server action. Running the same schema on both ends eliminates drift. See [[frontend/react-forms]] and [[frontend/nextjs-server-actions]] for the server-side counterpart.

## Initialize the form with `useForm` and `zodResolver`

Pass `zodResolver` as the `resolver` and set `defaultValues` for every field in the schema. Missing default values produce uncontrolled-to-controlled warnings.

```tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

function ProfileForm() {
  const form = useForm<ProfileFormValues>({
    resolver: zodResolver(profileSchema),
    defaultValues: {
      displayName: "",
      email: "",
      bio: "",
    },
  });

  function onSubmit(values: ProfileFormValues) {
    // values is typed and validated
    console.log(values);
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        {/* fields go here */}
      </form>
    </Form>
  );
}
```

`<Form {...form}>` spreads the RHF form context so every `FormField` inside it can call `useFormContext()` without prop drilling. Omitting it breaks all nested fields. See [[frontend/shadcn-composition]] for why `asChild` is not needed on the `<form>` element here.

## Wrap every input in `FormField` with a `render` prop

`FormField` connects an input to the RHF context by `name`. It passes a `field` object to the render prop; spread it onto the input.

```tsx
<FormField
  control={form.control}
  name="displayName"
  render={({ field }) => (
    <FormItem>
      <FormLabel>Display name</FormLabel>
      <FormControl>
        <Input placeholder="Aria Nakamura" {...field} />
      </FormControl>
      <FormDescription>This is your public profile name.</FormDescription>
      <FormMessage />
    </FormItem>
  )}
/>
```

The sub-components each have a job: `FormItem` provides spacing and a label association context; `FormLabel` renders an accessible `<label>` wired to the input's id; `FormControl` injects the `id`, `aria-describedby`, and `aria-invalid` attributes onto the child; `FormDescription` provides hint text; `FormMessage` renders the current error string. Do not skip `FormControl`. Without it, `aria-invalid` is never set and screen readers cannot announce the error.

## Use `FormMessage` for all error display; do not write your own

`FormMessage` reads from `fieldState.error` and renders the message only when an error exists. It uses `role="alert"` internally so screen readers announce the error on validation.

```tsx
<FormMessage />
// Renders: <p role="alert" class="text-sm font-medium text-destructive">Name must be at least 2 characters</p>
// Renders nothing when there is no error.
```

Do not write a conditional `{errors.displayName && <p>}` beside a shadcn field. The two patterns interfere: `FormMessage` manages its own presence; a second error paragraph doubles the announcement to screen readers and creates a visual conflict. See [[frontend/forms]] for the base accessibility rule.

## Compose complex inputs by wrapping them in `FormControl`

Custom inputs, date pickers, comboboxes, and rich-text editors need the same `id`/`aria-describedby` injection that `FormControl` provides. Wrap any custom input in `FormControl` and spread the field object onto it.

```tsx
<FormField
  control={form.control}
  name="role"
  render={({ field }) => (
    <FormItem>
      <FormLabel>Role</FormLabel>
      <Select onValueChange={field.onChange} defaultValue={field.value}>
        <FormControl>
          <SelectTrigger>
            <SelectValue placeholder="Select a role" />
          </SelectTrigger>
        </FormControl>
        <SelectContent>
          <SelectItem value="admin">Admin</SelectItem>
          <SelectItem value="viewer">Viewer</SelectItem>
        </SelectContent>
      </Select>
      <FormMessage />
    </FormItem>
  )}
/>
```

For a Select, `onValueChange` maps to `field.onChange` and `defaultValue` maps to `field.value`. RHF tracks the value; shadcn renders it. The same pattern applies to `Checkbox`, `RadioGroup`, and `Switch`: map their change event to `field.onChange` and their value/checked state to `field.value`.

## Handle async server errors by setting them with `form.setError`

When a server action returns a field-level error (duplicate email, invalid token), call `form.setError` to inject it into the RHF state. The error then flows through `FormMessage` like a validation error.

```ts
async function onSubmit(values: ProfileFormValues) {
  const result = await updateProfile(values);
  if (result.error?.field === "email") {
    form.setError("email", {
      type: "server",
      message: result.error.message,
    });
    return;
  }
}
```

Use `"root"` as the field name for errors that are not tied to a specific field (e.g., network failure). Render root errors with `<FormMessage />` outside the field hierarchy or read them from `form.formState.errors.root`. See [[frontend/nextjs-server-actions]] for the action side of this pattern.

## Related

- [[frontend/shadcn]]
- [[frontend/shadcn-composition]]
- [[frontend/react-forms]]
- [[frontend/forms]]
- [[frontend/react]]
- [[frontend/nextjs-server-actions]]
- [[coding/typescript]]
