Skip to main content
友田 陽大
React forms
React
React Hook Form
shadcn/ui
Zod
TypeScript
Next.js
フォーム
型安全
フロントエンド

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
Reading time
11 min read
Author
友田 陽大
Share

This article, with the shadcn/ui Form official documentation 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; for the validation schema, the Zod 4 practical guide.

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)

PartTrue identityRole
Forman alias of FormProviderdistributes the form context. Pass {...form}
FormFielda wrapper of Controllersubscribes to one field. Takes control / name / render
FormItemlayout + unique id generationchild parts share this id to tie accessibility
FormLabelLabelauto-ties htmlFor to the input's id. Color changes on error
FormControlSlot (asChild)injects id / aria-invalid / aria-describedby into the child input
FormDescriptionauxiliary texta supplementary note. Incorporated into aria-describedby
FormMessageerror displayauto-displays errors[name].message. Renders nothing if empty
useFormFieldhookextracts 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)

# 初期化(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.


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

"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).

// 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 —

<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 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.


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

<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>
  )}
/>
<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).

The iron rule of FormControl: FormControl wraps only one child into which it injects aria. Don't wrap multiple inputs together or place two FormControls. 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>.

"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:

<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.


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.

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.


9. Common pitfalls

SymptomCauseAction
useFormField should be used within FormField errorused FormItem, etc., outside FormFieldalways place parts inside FormField's render
The error text doesn't appearforgot to place FormMessage / name doesn't match the schemaplace FormMessage, make name type-safe with Path<T>
Select/Checkbox isn't controlleddidn't bridge field to the bare value/onChangeassign field.onChange to onValueChange/onCheckedChange
aria-describedby is doubled/brokenplaced multiple FormControlsFormControl wraps only one input to inject into
A number arrives as a stringconversion is needed as with registermake Input type="number" and convert on the Zod side with z.coerce.number(), etc.
Submission can be mashedthe button isn't disabledattach 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).

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.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

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.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading