この記事は shadcn/ui Form 公式ドキュメント と、コンポーネントの実装(components/ui/form.tsx)を一次情報に、「shadcn/ui のフォームは結局どういう仕組みで、どう書くのが正解か」を本番品質まで掘り下げます。前提となる React Hook Form の中核は React Hook Form 完全ガイド、検証スキーマは Zod 4 実践ガイド を参照してください。
検証した版(2026年6月時点): shadcn/ui(New York / neutral)、
react-hook-formv7 系、@hookform/resolversv5 系、zodv4、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 一発)
# 初期化(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 デザインシステム実践ガイド を参照。
3. 最小フォーム:これが全パターンの雛形
"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 の実装より)。
// FormControl(抜粋・概念)
<Slot
id={formItemId}
aria-describedby={
!error ? formDescriptionId : `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
/>
つまり、あなたが <Input {...field} /> と書くだけで、レンダリング後の DOM はこうなります——
<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 完全ガイド で「手で書くべき a11y 3点セット」として説明した aria-invalid + aria-describedby + エラー文の関連付けが、部品を並べるだけで満たされる——これが shadcn を選ぶ最大の実利です。WCAG 2.2 の網羅は React/Next.js アクセシビリティ実装ガイド を参照。
5. Radix 制御 UI を繋ぐ(Select / Checkbox / RadioGroup / Switch)
Input のようなネイティブ入力は {...field} で済みますが、Radix の制御コンポーネントは value/onChange の名前が違います。field を手で割り当てるだけで繋がります。
5-1. Select
<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(同意チェックなど)
<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 の発想そのものです)。
FormControlの鉄則:FormControlはariaを注入する子を1つだけ包みます。複数入力をまとめて包んだり、FormControlを2つ置いたりしないこと。チェックボックス群のように複数入力を1フィールドにするなら、fieldset/legendで囲み、各入力は素の Radix で並べてエラーはFormMessage1つに集約します。
6. 型安全な「薄い再利用ラッパ」——ただし作りすぎない(YAGNI)
同じ FormField > FormItem > ... の並びが何十回も出ると DRY が気になります。汎用テキスト入力くらいは薄くラップする価値があります。型は Control<T> と Path<T> で安全に。
"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>
)}
/>
);
}
使う側:
<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 ガイド に詳述しています。
8. テスト:ラベル経由で操作すれば a11y まで同時に検証できる
shadcn が id/aria を正しく配線している証拠は、getByLabelText でフォームを操作できることです。実装の内部状態ではなく「ユーザーが見るもの」を検証します。
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 実践ガイド を参照。
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 完全ガイド)。
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 のフォームを正しく捉えると、学習も実装も一気に軽くなります——
- 正体は薄いラッパ。 状態は RHF、検証は Zod、shadcn は
id/ariaの配線を持つだけ。 - 唯一のパターンは
FormField > FormItem > FormLabel + FormControl(入力) + FormDescription + FormMessage。 FormControlがアクセシビリティの要——aria-invalid/aria-describedbyを自動注入する。- 制御 UI は
fieldを橋渡しするだけ。InputもSelectもCheckboxも同じ発想。 - 所有コードだから直せる——壊れても、デザインを変えたくても、自分のリポジトリの中で完結する。
結果として、アクセシブルで型安全なフォームを、最小のボイラープレートで量産できます。これは見た目の速さだけでなく、保守性(部品の単一責務)とユーザー体験(支援技術への正しい通知)を同時に底上げする設計上の選択です。
デザインシステムを前提にした本格的なフォーム設計・既存フォームの a11y/型安全化が必要な場合は、お気軽にご相談ください。 下記の事例では、業界の基幹業務を支える B2B SaaS のフォーム群を、型安全・アクセシビリティ・保守性を重視して設計・実装した過程を紹介しています。