メインコンテンツへスキップ
友田 陽大
Reactフォーム実装
React
React Hook Form
shadcn/ui
Zod
TypeScript
Next.js
フォーム
型安全
フロントエンド

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 の接続、型安全な再利用部品化、テスト、落とし穴までを本番品質の実コードで解説します。

公開日
読了時間
11分
著者
友田 陽大
シェア

この記事は 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-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 が足すのは 「ラベル・説明・エラーと入力を idaria-* で正しく結ぶ」配線だけ。

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


1. 構成部品の地図(7つ+1フック)

部品正体役割
FormFormProvider の別名フォーム文脈を配る。{...form} を渡す
FormFieldController のラッパ1フィールドを購読。control / name / render を取る
FormItemレイアウト+一意 id 生成この id を子部品が共有してアクセシビリティを結ぶ
FormLabelLabelhtmlFor を入力の id に自動で結ぶ。エラー時に色が変わる
FormControlSlot(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 formcomponents/ui/form.tsx を生成し、依存(react-hook-form@hookform/resolverszod)も導入します。生成物はあなたのリポジトリの一部なので、デザインや挙動を直接調整できます(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 }) => ...}fieldonChange/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>
  • FormLabelhtmlForformItemId に自動で結ばれる(ラベルクリックでフォーカス、読み上げで関連付け)。
  • aria-invalid はエラーの有無で切り替わる。
  • aria-describedby はエラーが出た瞬間に説明文+エラー文の両方を指す。

React Hook Form 完全ガイド で「手で書くべき a11y 3点セット」として説明した aria-invalidaria-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/checkedfield.value を渡すだけ。**「RHF の field を、その UI の入出力プロップに橋渡しする」**という1つの考え方で全制御 UI を統一できます(これは shadcn 固有ではなく React Hook Form の Controller の発想そのものです)。

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


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 の外で使った必ず FormFieldrender 内に部品を置く
エラー文が出ないFormMessage を置き忘れ/name がスキーマと不一致FormMessage を置き、namePath<T> で型安全に
Select/Checkbox が制御されないfield を素の value/onChange に橋渡ししていないonValueChange/onCheckedChangefield.onChange を割当
aria-describedby が二重・壊れるFormControl を複数置いたFormControl は注入対象の入力を1つだけ包む
数値が文字列で届くregister 同様に変換が要るInputtype="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 の FormFieldController のラッパに過ぎないので、他の制御 UI も ControlleruseControllerfield を橋渡しすれば同型で繋がります。違いは「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 を橋渡しするだけ。InputSelectCheckbox も同じ発想。
  5. 所有コードだから直せる——壊れても、デザインを変えたくても、自分のリポジトリの中で完結する。

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

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

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通業界のDXを実現したB2BサブスクリプションSaaS

ケーススタディを見る