# shadcn/ui × React Hook Form × Zod 実践ガイド【2026年最新】— アクセシブルなフォーム部品を最短で本番品質に

> shadcn/ui の Form プリミティブ（Form / FormField / FormItem / FormControl / FormMessage）は React Hook Form と Zod の上に構築され、aria-describedby・aria-invalid・ラベル連携を自動配線します。公式の構成に忠実に、最小フォームから Select / Checkbox / RadioGroup など Radix 制御 UI の接続、型安全な再利用部品化、テスト、落とし穴までを本番品質の実コードで解説します。

- 公開日: 2026-06-26
- 著者: 友田 陽大
- タグ: React, React Hook Form, shadcn/ui, Zod, TypeScript, Next.js, フォーム, 型安全, フロントエンド
- URL: https://tomodahinata.com/blog/react-hook-form-shadcn-ui-zod-form-components-guide

## 要点

- shadcn/ui の Form は独立した UI ライブラリではなく、React Hook Form（Context）＋ Radix ＋ Zod を束ねる薄い構成部品の集合
- FormField＝Controller のラッパ、FormControl＝aria-invalid/aria-describedby/id を自動配線するアクセシビリティの要
- ネイティブ入力も Select/Checkbox/RadioGroup などの Radix 制御 UI も、同じ FormField の render で型安全に繋がる
- FormMessage は errors[name].message を自動表示するので、Zod のメッセージがそのまま支援技術に届く
- 薄いラッパなので所有コードとして読める——壊れたら自分で直せる。これが vendoring 方式の最大の利点

---

この記事は [shadcn/ui Form 公式ドキュメント](https://ui.shadcn.com/docs/components/form) と、コンポーネントの実装（`components/ui/form.tsx`）を一次情報に、「shadcn/ui のフォームは結局どういう仕組みで、どう書くのが正解か」を本番品質まで掘り下げます。前提となる React Hook Form の中核は [React Hook Form 完全ガイド](/blog/react-hook-form)、検証スキーマは [Zod 4 実践ガイド](/blog/zod) を参照してください。

> **検証した版（2026年6月時点）：** shadcn/ui（New York / neutral）、`react-hook-form` v7 系、`@hookform/resolvers` v5 系、`zod` v4、Radix UI。

---

## 0. まず結論：shadcn の Form は「ライブラリ」ではなく「配線済みの部品」

多くの人が誤解しますが、**shadcn/ui の Form は npm パッケージではありません**。`npx shadcn add form` を実行すると、`components/ui/form.tsx` という **あなたのコード**が生成されます。中身は React Hook Form の `Controller` と Radix のプリミティブを、**アクセシビリティの配線込みで束ねた薄いラッパ**です。

これが意味すること——

- **検証ロジックは持たない。** 検証は今まで通り `zodResolver` ＋ Zod。
- **状態も持たない。** 状態は React Hook Form。
- shadcn が足すのは **「ラベル・説明・エラーと入力を `id` と `aria-*` で正しく結ぶ」配線**だけ。

つまり shadcn の Form を理解する＝**「どの部品がどの `aria` を吐くか」を理解する**こと。ここを押さえれば、自分で `aria-describedby` を書く手間なくアクセシブルなフォームが量産できます。

---

## 1. 構成部品の地図（7つ＋1フック）

| 部品 | 正体 | 役割 |
| --- | --- | --- |
| `Form` | `FormProvider` の別名 | フォーム文脈を配る。`{...form}` を渡す |
| `FormField` | `Controller` のラッパ | 1フィールドを購読。`control` / `name` / `render` を取る |
| `FormItem` | レイアウト＋一意 `id` 生成 | この `id` を子部品が共有してアクセシビリティを結ぶ |
| `FormLabel` | `Label` | `htmlFor` を入力の `id` に自動で結ぶ。エラー時に色が変わる |
| `FormControl` | `Slot`（asChild） | 子入力へ `id` / `aria-invalid` / `aria-describedby` を注入 |
| `FormDescription` | 補助テキスト | 補足文。`aria-describedby` に組み込まれる |
| `FormMessage` | エラー表示 | `errors[name].message` を自動表示。空なら描画しない |
| `useFormField` | フック | `formItemId` / `formMessageId` / `error` 等を取り出す |

要は **`FormField` で1フィールドを囲み、その中に `FormItem > FormLabel + FormControl(入力) + FormDescription + FormMessage` を並べる**——これが唯一のパターンです。

---

## 2. セットアップ（CLI 一発）

```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` が `components/ui/form.tsx` を生成し、依存（`react-hook-form`・`@hookform/resolvers`・`zod`）も導入します。生成物は**あなたのリポジトリの一部**なので、デザインや挙動を直接調整できます（vendoring）。shadcn の設計思想は [shadcn/ui デザインシステム実践ガイド](/blog/shadcn-ui-design-system-architecture-production-guide) を参照。

---

## 3. 最小フォーム：これが全パターンの雛形

```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>
  );
}
```

ポイント：

- `<Form {...form}>` は `FormProvider` なので、配下のどの部品も `control` をプロップで受け取らずに済みます（バケツリレーの解消）。
- `render={({ field }) => ...}` の `field`（`onChange`/`onBlur`/`value`/`ref`/`name`）を入力に**そのまま**スプレッドします。
- `FormMessage` には**何も渡さない**——`errors.username.message` を自動で拾います。

---

## 4. アクセシビリティはどう「自動配線」されるか

ここが shadcn フォームの本体価値です。`FormControl` は内部で `useFormField()` を呼び、次を**入力に注入**します（`form.tsx` の実装より）。

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

つまり、あなたが `<Input {...field} />` と書くだけで、レンダリング後の DOM はこうなります——

```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`** の `htmlFor` は `formItemId` に自動で結ばれる（ラベルクリックでフォーカス、読み上げで関連付け）。
- **`aria-invalid`** はエラーの有無で切り替わる。
- **`aria-describedby`** はエラーが出た瞬間に**説明文＋エラー文の両方**を指す。

[React Hook Form 完全ガイド](/blog/react-hook-form) で「手で書くべき a11y 3点セット」として説明した `aria-invalid` ＋ `aria-describedby` ＋ エラー文の関連付けが、**部品を並べるだけで満たされる**——これが shadcn を選ぶ最大の実利です。WCAG 2.2 の網羅は [React/Next.js アクセシビリティ実装ガイド](/blog/react-nextjs-web-accessibility-wcag22-guide) を参照。

---

## 5. Radix 制御 UI を繋ぐ（Select / Checkbox / RadioGroup / Switch）

`Input` のようなネイティブ入力は `{...field}` で済みますが、Radix の制御コンポーネントは `value`/`onChange` の名前が違います。`field` を**手で割り当てる**だけで繋がります。

### 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（同意チェックなど）

```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 も同型

`onValueChange`（RadioGroup）や `onCheckedChange`（Switch）に `field.onChange` を、`value`/`checked` に `field.value` を渡すだけ。**「RHF の `field` を、その UI の入出力プロップに橋渡しする」**という1つの考え方で全制御 UI を統一できます（これは shadcn 固有ではなく [React Hook Form の `Controller`](/blog/react-hook-form) の発想そのものです）。

> **`FormControl` の鉄則：** `FormControl` は **`aria` を注入する子を1つだけ**包みます。複数入力をまとめて包んだり、`FormControl` を2つ置いたりしないこと。チェックボックス群のように複数入力を1フィールドにするなら、`fieldset`/`legend` で囲み、各入力は素の Radix で並べてエラーは `FormMessage` 1つに集約します。

---

## 6. 型安全な「薄い再利用ラッパ」——ただし作りすぎない（YAGNI）

同じ `FormField > FormItem > ...` の並びが何十回も出ると DRY が気になります。**汎用テキスト入力**くらいは薄くラップする価値があります。型は `Control<T>` と `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>
      )}
    />
  );
}
```

使う側：

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

> **ETC と YAGNI のバランス：** テキスト入力のように頻出かつ単純なものはラップして良い。一方、Select やファイルアップロードのように**1回しか出ない／毎回見た目が違う**ものを無理に汎用化すると、抽象が崩れて逆に読みにくくなります。「3回目に出たら抽出」を目安に、最初は素の `FormField` で書きましょう（KISS）。

---

## 7. Next.js App Router での置き場所

`useForm` はフックなので、フォーム本体は **`"use client"`** のコンポーネントに置きます。ページ（Server Component）はデータ取得とレイアウトに専念し、フォームを子に分離します。送信先が Server Action の場合は、**同じ Zod スキーマでサーバー側でも再検証**してください（クライアントは信用しない）。この二重検証とプログレッシブ・エンハンスメントの作法は [React Hook Form × Server Actions ガイド](/blog/react-hook-form-nextjs-server-actions-useactionstate-guide) に詳述しています。

---

## 8. テスト：ラベル経由で操作すれば a11y まで同時に検証できる

shadcn が `id`/`aria` を正しく配線している証拠は、**`getByLabelText` でフォームを操作できる**ことです。実装の内部状態ではなく「ユーザーが見るもの」を検証します。

```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("ユーザー名")` が通る＝ラベルと入力が `id`/`htmlFor` で結ばれている証明。E2E まで含めた設計は [Playwright E2E 実践ガイド](/blog/playwright-e2e-testing-production-design-guide) を参照。

---

## 9. よくある落とし穴

| 症状 | 原因 | 対処 |
| --- | --- | --- |
| `useFormField should be used within FormField` エラー | `FormItem` などを `FormField` の外で使った | 必ず `FormField` の `render` 内に部品を置く |
| エラー文が出ない | `FormMessage` を置き忘れ／`name` がスキーマと不一致 | `FormMessage` を置き、`name` を `Path<T>` で型安全に |
| Select/Checkbox が制御されない | `field` を素の `value`/`onChange` に橋渡ししていない | `onValueChange`/`onCheckedChange` に `field.onChange` を割当 |
| `aria-describedby` が二重・壊れる | `FormControl` を複数置いた | `FormControl` は注入対象の入力を1つだけ包む |
| 数値が文字列で届く | `register` 同様に変換が要る | `Input` を `type="number"` にし、Zod 側で `z.coerce.number()` 等で変換 |
| 送信を連打できてしまう | ボタンを無効化していない | `disabled={form.formState.isSubmitting}` を付ける |

---

## 10. FAQ

**Q. shadcn の Form と React Hook Form、どっちを学ぶべき？**
A. 両方ですが**順番は RHF が先**。shadcn は RHF の上の薄い化粧で、状態と検証の実体は RHF/Zod です。RHF を理解していれば shadcn は数分で乗れます（[React Hook Form 完全ガイド](/blog/react-hook-form)）。

**Q. MUI / Ant Design でも同じ考え方で繋がる？**
A. はい。shadcn の `FormField` は `Controller` のラッパに過ぎないので、他の制御 UI も `Controller`／`useController` で `field` を橋渡しすれば同型で繋がります。違いは「a11y 配線を自分で書くか、shadcn のように部品化しておくか」だけ。

**Q. `FormMessage` のメッセージはどこから来る？**
A. `errors[name].message`、つまり **Zod のスキーマに書いた文言**です。日本語メッセージを Zod に集約すれば、表示も支援技術への通知も一元化されます（DRY）。

**Q. デザインを大きく変えたい。**
A. `components/ui/form.tsx` は自分のコードなので直接編集できます。`FormMessage` の見た目やレイアウト規約（`space-y-6` など）を変えても、他プロジェクトを壊しません。これが vendoring の強み。

**Q. Server Components で使える？**
A. `useForm` を使う以上 `"use client"` 必須です。サーバー処理は Server Action に分け、同じ Zod で再検証します。

---

## まとめ：shadcn の Form は「a11y 配線済みの RHF」

shadcn/ui のフォームを正しく捉えると、学習も実装も一気に軽くなります——

1. **正体は薄いラッパ。** 状態は RHF、検証は Zod、shadcn は `id`/`aria` の配線を持つだけ。
2. **唯一のパターン**は `FormField > FormItem > FormLabel + FormControl(入力) + FormDescription + FormMessage`。
3. **`FormControl` がアクセシビリティの要**——`aria-invalid`/`aria-describedby` を自動注入する。
4. **制御 UI は `field` を橋渡し**するだけ。`Input` も `Select` も `Checkbox` も同じ発想。
5. **所有コードだから直せる**——壊れても、デザインを変えたくても、自分のリポジトリの中で完結する。

結果として、**アクセシブルで型安全なフォームを、最小のボイラープレートで量産**できます。これは見た目の速さだけでなく、保守性（部品の単一責務）とユーザー体験（支援技術への正しい通知）を同時に底上げする設計上の選択です。

**デザインシステムを前提にした本格的なフォーム設計・既存フォームの a11y/型安全化が必要な場合は、お気軽にご相談ください。** 下記の事例では、業界の基幹業務を支える B2B SaaS のフォーム群を、型安全・アクセシビリティ・保守性を重視して設計・実装した過程を紹介しています。
