# shadcn/ui × React Hook Form × Zod practical guide [latest 2026] — accessible form parts to production quality by the shortest path

> shadcn/ui's Form primitives (Form / FormField / FormItem / FormControl / FormMessage) are built on React Hook Form and Zod, and auto-wire aria-describedby, aria-invalid, and label linkage. Faithful to the official composition, it explains, in production-quality real code, from a minimal form to connecting Radix controlled UI like Select / Checkbox / RadioGroup, type-safe reusable componentization, testing, and pitfalls.

- Published: 2026-06-26
- Author: 友田 陽大
- Tags: React, React Hook Form, shadcn/ui, Zod, TypeScript, Next.js, フォーム, 型安全, フロントエンド
- URL: https://tomodahinata.com/en/blog/react-hook-form-shadcn-ui-zod-form-components-guide
- Category: React forms
- Pillar guide: https://tomodahinata.com/en/blog/react-hook-form

## Key points

- shadcn/ui's Form isn't an independent UI library but a set of thin composition parts that bundle React Hook Form (Context) + Radix + Zod.
- FormField = a wrapper of Controller; FormControl = the accessibility crux that auto-wires aria-invalid/aria-describedby/id.
- Both native inputs and Radix controlled UI like Select/Checkbox/RadioGroup connect type-safely with the same FormField render.
- FormMessage auto-displays errors[name].message, so Zod's message reaches assistive technology as-is.
- Being a thin wrapper, it reads as your owned code — if it breaks, you can fix it yourself. This is the biggest advantage of the vendoring method.

---

This article, with the [shadcn/ui Form official documentation](https://ui.shadcn.com/docs/components/form) and the component's implementation (`components/ui/form.tsx`) as primary sources, digs into "what mechanism shadcn/ui's form is, after all, and how to write it correctly" up to production quality. For the core of the prerequisite React Hook Form, see the [React Hook Form complete guide](/blog/react-hook-form); for the validation schema, the [Zod 4 practical guide](/blog/zod).

> **The versions verified (as of June 2026):** shadcn/ui (New York / neutral), `react-hook-form` v7 family, `@hookform/resolvers` v5 family, `zod` v4, Radix UI.

---

## 0. First, the conclusion: shadcn's Form is not a "library" but "pre-wired parts"

Many people misunderstand, but **shadcn/ui's Form isn't an npm package.** Running `npx shadcn add form` generates **your code** called `components/ui/form.tsx`. The contents are **a thin wrapper that bundles React Hook Form's `Controller` and Radix's primitives, including the accessibility wiring.**

What this means —

- **It holds no validation logic.** Validation is, as before, `zodResolver` + Zod.
- **It holds no state either.** State is React Hook Form.
- What shadcn adds is only the **wiring that "correctly ties the label, description, and error to the input with `id` and `aria-*`."**

In other words, understanding shadcn's Form = **understanding "which part emits which `aria`."** If you hold this down, you can mass-produce accessible forms without the effort of writing `aria-describedby` yourself.

---

## 1. The map of the composition parts (7 + 1 hook)

| Part | True identity | Role |
| --- | --- | --- |
| `Form` | an alias of `FormProvider` | distributes the form context. Pass `{...form}` |
| `FormField` | a wrapper of `Controller` | subscribes to one field. Takes `control` / `name` / `render` |
| `FormItem` | layout + unique `id` generation | child parts share this `id` to tie accessibility |
| `FormLabel` | `Label` | auto-ties `htmlFor` to the input's `id`. Color changes on error |
| `FormControl` | `Slot` (asChild) | injects `id` / `aria-invalid` / `aria-describedby` into the child input |
| `FormDescription` | auxiliary text | a supplementary note. Incorporated into `aria-describedby` |
| `FormMessage` | error display | auto-displays `errors[name].message`. Renders nothing if empty |
| `useFormField` | hook | extracts `formItemId` / `formMessageId` / `error`, etc. |

In short, **surround one field with `FormField`, and inside it line up `FormItem > FormLabel + FormControl(input) + FormDescription + FormMessage`** — this is the only pattern.

---

## 2. Setup (one CLI shot)

```bash
# 初期化（New York / neutral などを対話で選択）
npx shadcn@latest init

# Form 一式＋使う入力を追加（react-hook-form / @hookform/resolvers / zod も自動で入る）
npx shadcn@latest add form input button select checkbox textarea
```

`add form` generates `components/ui/form.tsx` and introduces the dependencies (`react-hook-form`, `@hookform/resolvers`, `zod`). The generated thing is **part of your repository,** so you can directly adjust the design and behavior (vendoring). For shadcn's design philosophy, see the [shadcn/ui design-system practical guide](/blog/shadcn-ui-design-system-architecture-production-guide).

---

## 3. The minimal form: this is the template for all patterns

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

// 検証・型・文言の単一の正（Single Source of Truth）
const profileSchema = z.object({
  username: z
    .string()
    .min(2, { error: "2文字以上で入力してください" })
    .max(32, { error: "32文字以内で入力してください" }),
});
type ProfileForm = z.infer<typeof profileSchema>;

export function ProfileForm() {
  const form = useForm<ProfileForm>({
    resolver: zodResolver(profileSchema),
    defaultValues: { username: "" }, // 全フィールドに初期値を（isDirty を正しく出すため）
    mode: "onTouched",
  });

  const onSubmit = (values: ProfileForm) => {
    // ここに到達した時点で values は検証済み・型付き
    console.log(values);
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6" noValidate>
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>ユーザー名</FormLabel>
              <FormControl>
                <Input placeholder="taro" {...field} />
              </FormControl>
              <FormDescription>公開プロフィールに表示されます。</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit" disabled={form.formState.isSubmitting}>
          保存
        </Button>
      </form>
    </Form>
  );
}
```

Points:

- `<Form {...form}>` is `FormProvider`, so any part below it doesn't need to receive `control` as a prop (resolves prop drilling).
- Spread the `field` (`onChange`/`onBlur`/`value`/`ref`/`name`) of `render={({ field }) => ...}` **as-is** onto the input.
- **Pass nothing** to `FormMessage` — it automatically picks up `errors.username.message`.

---

## 4. How accessibility is "auto-wired"

This is the main value of shadcn forms. `FormControl` internally calls `useFormField()` and **injects the following into the input** (from `form.tsx`'s implementation).

```tsx
// FormControl（抜粋・概念）
<Slot
  id={formItemId}
  aria-describedby={
    !error ? formDescriptionId : `${formDescriptionId} ${formMessageId}`
  }
  aria-invalid={!!error}
/>
```

In other words, just by writing `<Input {...field} />`, the DOM after rendering becomes this —

```html
<label for="«r1»-form-item">ユーザー名</label>
<input
  id="«r1»-form-item"
  aria-invalid="true"
  aria-describedby="«r1»-form-item-description «r1»-form-item-message"
/>
<p id="«r1»-form-item-description">公開プロフィールに表示されます。</p>
<p id="«r1»-form-item-message">2文字以上で入力してください</p>
```

- **`FormLabel`**'s `htmlFor` is auto-tied to `formItemId` (focus on label click, association in screen-reading).
- **`aria-invalid`** switches by the presence of an error.
- **`aria-describedby`** points to **both the description and the error text** the moment an error appears.

The association of `aria-invalid` + `aria-describedby` + the error text, which I explained in the [React Hook Form complete guide](/blog/react-hook-form) as "the a11y three-piece set you should write by hand," is **satisfied just by lining up parts** — this is the biggest practical benefit of choosing shadcn. For the coverage of WCAG 2.2, see the [React/Next.js accessibility implementation guide](/blog/react-nextjs-web-accessibility-wcag22-guide).

---

## 5. Connecting Radix controlled UI (Select / Checkbox / RadioGroup / Switch)

A native input like `Input` is fine with `{...field}`, but Radix's controlled components have different names for `value`/`onChange`. They connect just by **assigning `field` by hand.**

### 5-1. Select

```tsx
<FormField
  control={form.control}
  name="plan"
  render={({ field }) => (
    <FormItem>
      <FormLabel>プラン</FormLabel>
      <Select onValueChange={field.onChange} defaultValue={field.value}>
        <FormControl>
          {/* FormControl は子1つにだけ aria を注入する。トリガを包む */}
          <SelectTrigger>
            <SelectValue placeholder="選択してください" />
          </SelectTrigger>
        </FormControl>
        <SelectContent>
          <SelectItem value="free">Free</SelectItem>
          <SelectItem value="pro">Pro</SelectItem>
        </SelectContent>
      </Select>
      <FormMessage />
    </FormItem>
  )}
/>
```

### 5-2. Checkbox (a consent check, etc.)

```tsx
<FormField
  control={form.control}
  name="agree"
  render={({ field }) => (
    <FormItem className="flex flex-row items-start gap-3">
      <FormControl>
        <Checkbox checked={field.value} onCheckedChange={field.onChange} />
      </FormControl>
      <div className="space-y-1 leading-none">
        <FormLabel>利用規約に同意する</FormLabel>
        <FormMessage />
      </div>
    </FormItem>
  )}
/>
```

### 5-3. RadioGroup / Switch are the same form

Just pass `field.onChange` to `onValueChange` (RadioGroup) or `onCheckedChange` (Switch), and `field.value` to `value`/`checked`. You can unify all controlled UI with one idea: **"bridge RHF's `field` to that UI's input/output props"** (this isn't shadcn-specific but the very idea of [React Hook Form's `Controller`](/blog/react-hook-form)).

> **The iron rule of `FormControl`:** `FormControl` wraps **only one child into which it injects `aria`.** Don't wrap multiple inputs together or place two `FormControl`s. If you make multiple inputs one field, like a checkbox group, surround them with `fieldset`/`legend`, line up each input as bare Radix, and consolidate errors into one `FormMessage`.

---

## 6. A type-safe "thin reusable wrapper" — but don't make too many (YAGNI)

When the same `FormField > FormItem > ...` arrangement appears dozens of times, DRY becomes a concern. **A general-purpose text input** is worth thinly wrapping at least. Make the types safe with `Control<T>` and `Path<T>`.

```tsx
"use client";
import { type Control, type FieldValues, type Path } from "react-hook-form";
import {
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";

type TextFieldProps<T extends FieldValues> = {
  control: Control<T>;
  name: Path<T>; // ✅ スキーマに存在するキーしか渡せない
  label: string;
  description?: string;
  type?: React.HTMLInputTypeAttribute;
  placeholder?: string;
};

// SRP：責務は「1つのテキスト入力＋ラベル＋説明＋エラー」だけ
export function TextField<T extends FieldValues>({
  control,
  name,
  label,
  description,
  type = "text",
  placeholder,
}: TextFieldProps<T>) {
  return (
    <FormField
      control={control}
      name={name}
      render={({ field }) => (
        <FormItem>
          <FormLabel>{label}</FormLabel>
          <FormControl>
            <Input type={type} placeholder={placeholder} {...field} />
          </FormControl>
          {description && <FormDescription>{description}</FormDescription>}
          <FormMessage />
        </FormItem>
      )}
    />
  );
}
```

The using side:

```tsx
<TextField control={form.control} name="email" label="メールアドレス" type="email" />
<TextField control={form.control} name="company" label="会社名" />
```

> **The balance of ETC and YAGNI:** something frequent and simple like a text input is fine to wrap. On the other hand, forcibly generalizing something that **appears only once / looks different every time,** like Select or file upload, breaks the abstraction and conversely makes it harder to read. With "extract on the third occurrence" as a guideline, write with bare `FormField` at first (KISS).

---

## 7. Where to place it in the Next.js App Router

Since `useForm` is a hook, place the form body in a **`"use client"`** component. The page (Server Component) concentrates on data fetching and layout, and separates the form into a child. If the submission destination is a Server Action, **re-validate on the server side too with the same Zod schema** (don't trust the client). This double validation and the practice of progressive enhancement are detailed in the [React Hook Form × Server Actions guide](/blog/react-hook-form-nextjs-server-actions-useactionstate-guide).

---

## 8. Testing: operating via the label simultaneously verifies even a11y

The proof that shadcn correctly wires `id`/`aria` is that **you can operate the form with `getByLabelText`.** Verify "what the user sees," not the implementation's internal state.

```tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { ProfileForm } from "./profile-form";

describe("ProfileForm", () => {
  it("短すぎる入力でエラーを読み上げ領域に出す", async () => {
    render(<ProfileForm />);
    await userEvent.type(screen.getByLabelText("ユーザー名"), "a");
    await userEvent.tab(); // onTouched なので blur で検証
    expect(await screen.findByText("2文字以上で入力してください")).toBeInTheDocument();
  });

  it("妥当な入力で送信できる", async () => {
    const onSubmit = vi.fn();
    render(<ProfileForm onSubmit={onSubmit} />);
    await userEvent.type(screen.getByLabelText("ユーザー名"), "taro");
    await userEvent.click(screen.getByRole("button", { name: "保存" }));
    await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ username: "taro" }));
  });
});
```

`getByLabelText("ユーザー名")` passing = proof that the label and the input are tied by `id`/`htmlFor`. For a design including E2E, see the [Playwright E2E practical guide](/blog/playwright-e2e-testing-production-design-guide).

---

## 9. Common pitfalls

| Symptom | Cause | Action |
| --- | --- | --- |
| `useFormField should be used within FormField` error | used `FormItem`, etc., outside `FormField` | always place parts inside `FormField`'s `render` |
| The error text doesn't appear | forgot to place `FormMessage` / `name` doesn't match the schema | place `FormMessage`, make `name` type-safe with `Path<T>` |
| Select/Checkbox isn't controlled | didn't bridge `field` to the bare `value`/`onChange` | assign `field.onChange` to `onValueChange`/`onCheckedChange` |
| `aria-describedby` is doubled/broken | placed multiple `FormControl`s | `FormControl` wraps only one input to inject into |
| A number arrives as a string | conversion is needed as with `register` | make `Input` `type="number"` and convert on the Zod side with `z.coerce.number()`, etc. |
| Submission can be mashed | the button isn't disabled | attach `disabled={form.formState.isSubmitting}` |

---

## 10. FAQ

**Q. shadcn's Form or React Hook Form, which should I learn?**
A. Both, but **the order is RHF first.** shadcn is thin makeup on top of RHF, and the substance of state and validation is RHF/Zod. If you understand RHF, you can get on shadcn in minutes ([React Hook Form complete guide](/blog/react-hook-form)).

**Q. Does the same idea connect with MUI / Ant Design too?**
A. Yes. Since shadcn's `FormField` is merely a wrapper of `Controller`, other controlled UI connects in the same form if you bridge `field` with `Controller`/`useController`. The only difference is "whether you write the a11y wiring yourself, or componentize it like shadcn."

**Q. Where does `FormMessage`'s message come from?**
A. `errors[name].message`, that is, **the wording written in the Zod schema.** If you consolidate Japanese messages in Zod, both the display and the notification to assistive technology are unified (DRY).

**Q. I want to change the design greatly.**
A. Since `components/ui/form.tsx` is your own code, you can edit it directly. Changing the appearance of `FormMessage` or the layout conventions (`space-y-6`, etc.) doesn't break other projects. This is the strength of vendoring.

**Q. Can I use it in Server Components?**
A. As long as you use `useForm`, `"use client"` is mandatory. Separate server processing into a Server Action and re-validate with the same Zod.

---

## Summary: shadcn's Form is "RHF with a11y pre-wired"

If you grasp shadcn/ui's form correctly, both learning and implementation get lighter at once —

1. **The true identity is a thin wrapper.** State is RHF, validation is Zod, and shadcn only holds the `id`/`aria` wiring.
2. **The only pattern** is `FormField > FormItem > FormLabel + FormControl(input) + FormDescription + FormMessage`.
3. **`FormControl` is the accessibility crux** — it auto-injects `aria-invalid`/`aria-describedby`.
4. **For controlled UI, just bridge `field`.** `Input`, `Select`, and `Checkbox` are the same idea.
5. **Because it's owned code, you can fix it** — even if it breaks, or you want to change the design, it's self-contained inside your repository.

As a result, you can **mass-produce accessible, type-safe forms with the minimal boilerplate.** This is not only the speed of appearance but a design choice that simultaneously raises maintainability (the single responsibility of parts) and user experience (correct notification to assistive technology).

**If you need full-scale form design premised on a design system, or making an existing form a11y/type-safe, please feel free to consult me.** The case study below introduces the process of designing and implementing the form group of a B2B SaaS that supports an industry's core operations, with an emphasis on type safety, accessibility, and maintainability.
