Chuyển đến nội dung chính
友田 陽大
React forms
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 の接続、型安全な再利用部品化、テスト、落とし穴までを本番品質の実コードで解説します。

Published
Reading time
11 min read
Author
友田 陽大
Chia sẻ

この記事は 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 のフォーム群を、型安全・アクセシビリティ・保守性を重視して設計・実装した過程を紹介しています。

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Cũng đáng đọc