React Hook Form

React Hook Form と Zod を使った React フォーム構築。

このガイドでは React Hook Form を使ったフォームの構築方法を説明します。<Field /> コンポーネントでのフォーム作成、Zod を用いたスキーマ検証、エラーハンドリング、アクセシビリティなどをカバーします。

デモ

以下のフォームを作成します。シンプルなテキスト入力とテキストエリアを備えたフォームです。送信時にフォームデータを検証し、エラーがあれば表示します。

Component form-rhf-demo not found in registry.

アプローチ

このフォームは React Hook Form を使ってパフォーマンスと柔軟性を実現しています。<Field /> コンポーネントを使ってフォームを構築し、マークアップとスタイリングに完全な自由度を確保します。

  • React Hook Form の useForm フックでフォーム状態を管理。
  • 制御インプットに <Controller /> コンポーネントを使用。
  • <Field /> コンポーネントでアクセシブルなフォームを構築。
  • zodResolver で Zod によるクライアントサイド検証。

属成

<Controller /> コンポーネントと <Field /> コンポーネントを使ったフォームの基本例。

<Controller
  name="title"
  control={form.control}
  render={({ field, fieldState }) => (
    <Field data-invalid={fieldState.invalid}>
      <FieldLabel htmlFor={field.name}>Bug Title</FieldLabel>
      <Input
        {...field}
        id={field.name}
        aria-invalid={fieldState.invalid}
        placeholder="Login button not working on mobile"
        autoComplete="off"
      />
      <FieldDescription>
        Provide a concise title for your bug report.
      </FieldDescription>
      {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
    </Field>
  )}
/>

フォーム

フォームスキーマの作成

Zod スキーマでフォームの形式を定義します。

form.tsx
import * as z from "zod"
 
const formSchema = z.object({
  title: z
    .string()
    .min(5, "Bug title must be at least 5 characters.")
    .max(32, "Bug title must be at most 32 characters."),
  description: z
    .string()
    .min(20, "Description must be at least 20 characters.")
    .max(100, "Description must be at most 100 characters."),
})

フォームのセットアップ

useForm フックでフォームインスタンスを作成し、Zod リゾルバーで検証を追加します。

form.tsx
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
 
const formSchema = z.object({
  title: z
    .string()
    .min(5, "Bug title must be at least 5 characters.")
    .max(32, "Bug title must be at most 32 characters."),
  description: z
    .string()
    .min(20, "Description must be at least 20 characters.")
    .max(100, "Description must be at most 100 characters."),
})
 
export function BugReportForm() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      title: "",
      description: "",
    },
  })
 
  function onSubmit(data: z.infer<typeof formSchema>) {
    // Do something with the form values.
    console.log(data)
  }
 
  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      {/* ... */}
      {/* Build the form here */}
      {/* ... */}
    </form>
  )
}

フォームの構築

<Controller /><Field /> コンポーネントでフォームを作成できます。

完成

以上です。クライアントサイド検証付きの完全なアクセシブルなフォームができました。

フォームを送信すると、検証済みデータで onSubmit が呼び出されます。入力が無効な場合、React Hook Form が各フィールドの横にエラーを表示します。

検証

クライアントサイド検証

React Hook Form は Zod スキーマでフォームデータを検証します。スキーマを定義し、useFormresolver オプションに渡します。

example-form.tsx
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
 
const formSchema = z.object({
  title: z.string(),
  description: z.string().optional(),
})
 
export function ExampleForm() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      title: "",
      description: "",
    },
  })
}

検証モード

React Hook Form は複数の検証モードをサポートしています。

form.tsx
const form = useForm<z.infer<typeof formSchema>>({
  resolver: zodResolver(formSchema),
  mode: "onChange",
})
モード説明
"onChange"変更ごとに検証が実行されます。
"onBlur"blur 時に検証が実行されます。
"onSubmit"送信時に検証が実行されます(デフォルト)。
"onTouched"初回 blur 時、その後は変更ごとに検証が実行されます。
"all"blur と変更の両方で検証が実行されます。

エラーの表示

<FieldError /> でフィールドの横にエラーを表示します。スタイリングとアクセシビリティのため:

  • <Field /> コンポーネントに data-invalid を付けます。
  • <Input /><SelectTrigger /><Checkbox /> などのフォームコントロールに aria-invalid を付けます。
form.tsx
<Controller
  name="email"
  control={form.control}
  render={({ field, fieldState }) => (
    <Field data-invalid={fieldState.invalid}>
      <FieldLabel htmlFor={field.name}>Email</FieldLabel>
      <Input
        {...field}
        id={field.name}
        type="email"
        aria-invalid={fieldState.invalid}
      />
      {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
    </Field>
  )}
/>

フィールドタイプ別の使い方

Input

  • 入力フィールドには field オブジェクトを <Input /> にスプレッドします。
  • エラー表示には <Input />aria-invalid<Field />data-invalid を付けます。

Component form-rhf-input not found in registry.

シンプルなテキスト入力には、field オブジェクトを入力にスプレッドします。

form.tsx
<Controller
  name="name"
  control={form.control}
  render={({ field, fieldState }) => (
    <Field data-invalid={fieldState.invalid}>
      <FieldLabel htmlFor={field.name}>Name</FieldLabel>
      <Input {...field} id={field.name} aria-invalid={fieldState.invalid} />
      {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
    </Field>
  )}
/>

Textarea

  • テキストエリアフィールドには field オブジェクトを <Textarea /> にスプレッドします。
  • エラー表示には <Textarea />aria-invalid<Field />data-invalid を付けます。

Component form-rhf-textarea not found in registry.

テキストエリアフィールドには、field オブジェクトをテキストエリアにスプレッドします。

form.tsx
<Controller
  name="about"
  control={form.control}
  render={({ field, fieldState }) => (
    <Field data-invalid={fieldState.invalid}>
      <FieldLabel htmlFor="form-rhf-textarea-about">More about you</FieldLabel>
      <Textarea
        {...field}
        id="form-rhf-textarea-about"
        aria-invalid={fieldState.invalid}
        placeholder="I'm a software engineer..."
        className="min-h-[120px]"
      />
      <FieldDescription>
        Tell us more about yourself. This will be used to help us personalize
        your experience.
      </FieldDescription>
      {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
    </Field>
  )}
/>

Select

  • Select コンポーネントには <Select />field.valuefield.onChange を使用します。
  • エラー表示には <SelectTrigger />aria-invalid<Field />data-invalid を付けます。

Component form-rhf-select not found in registry.

form.tsx
<Controller
  name="language"
  control={form.control}
  render={({ field, fieldState }) => (
    <Field orientation="responsive" data-invalid={fieldState.invalid}>
      <FieldContent>
        <FieldLabel htmlFor="form-rhf-select-language">
          Spoken Language
        </FieldLabel>
        <FieldDescription>
          For best results, select the language you speak.
        </FieldDescription>
        {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
      </FieldContent>
      <Select
        name={field.name}
        value={field.value}
        onValueChange={field.onChange}
      >
        <SelectTrigger
          id="form-rhf-select-language"
          aria-invalid={fieldState.invalid}
          className="min-w-[120px]"
        >
          <SelectValue placeholder="Select" />
        </SelectTrigger>
        <SelectContent position="item-aligned">
          <SelectItem value="auto">Auto</SelectItem>
          <SelectItem value="en">English</SelectItem>
        </SelectContent>
      </Select>
    </Field>
  )}
/>

Checkbox

  • チェックボックスの配列には field.valuefield.onChange に配列操作を使用します。
  • エラー表示には <Checkbox />aria-invalid<Field />data-invalid を付けます。
  • 適切なスタイリングと間距のため、<FieldGroup />data-slot="checkbox-group" を付けてください。

Component form-rhf-checkbox not found in registry.

form.tsx
<Controller
  name="tasks"
  control={form.control}
  render={({ field, fieldState }) => (
    <FieldSet>
      <FieldLegend variant="label">Tasks</FieldLegend>
      <FieldDescription>
        Get notified when tasks you&apos;ve created have updates.
      </FieldDescription>
      <FieldGroup data-slot="checkbox-group">
        {tasks.map((task) => (
          <Field
            key={task.id}
            orientation="horizontal"
            data-invalid={fieldState.invalid}
          >
            <Checkbox
              id={`form-rhf-checkbox-${task.id}`}
              name={field.name}
              aria-invalid={fieldState.invalid}
              checked={field.value.includes(task.id)}
              onCheckedChange={(checked) => {
                const newValue = checked
                  ? [...field.value, task.id]
                  : field.value.filter((value) => value !== task.id)
                field.onChange(newValue)
              }}
            />
            <FieldLabel
              htmlFor={`form-rhf-checkbox-${task.id}`}
              className="font-normal"
            >
              {task.label}
            </FieldLabel>
          </Field>
        ))}
      </FieldGroup>
      {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
    </FieldSet>
  )}
/>

Radio Group

  • ラジオグループには <RadioGroup />field.valuefield.onChange を使用します。
  • エラー表示には <RadioGroupItem />aria-invalid<Field />data-invalid を付けます。

Component form-rhf-radiogroup not found in registry.

form.tsx
<Controller
  name="plan"
  control={form.control}
  render={({ field, fieldState }) => (
    <FieldSet>
      <FieldLegend>Plan</FieldLegend>
      <FieldDescription>
        You can upgrade or downgrade your plan at any time.
      </FieldDescription>
      <RadioGroup
        name={field.name}
        value={field.value}
        onValueChange={field.onChange}
      >
        {plans.map((plan) => (
          <FieldLabel key={plan.id} htmlFor={`form-rhf-radiogroup-${plan.id}`}>
            <Field orientation="horizontal" data-invalid={fieldState.invalid}>
              <FieldContent>
                <FieldTitle>{plan.title}</FieldTitle>
                <FieldDescription>{plan.description}</FieldDescription>
              </FieldContent>
              <RadioGroupItem
                value={plan.id}
                id={`form-rhf-radiogroup-${plan.id}`}
                aria-invalid={fieldState.invalid}
              />
            </Field>
          </FieldLabel>
        ))}
      </RadioGroup>
      {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
    </FieldSet>
  )}
/>

Switch

  • スイッチには <Switch />field.valuefield.onChange を使用します。
  • エラー表示には <Switch />aria-invalid<Field />data-invalid を付けます。

Component form-rhf-switch not found in registry.

form.tsx
<Controller
  name="twoFactor"
  control={form.control}
  render={({ field, fieldState }) => (
    <Field orientation="horizontal" data-invalid={fieldState.invalid}>
      <FieldContent>
        <FieldLabel htmlFor="form-rhf-switch-twoFactor">
          Multi-factor authentication
        </FieldLabel>
        <FieldDescription>
          Enable multi-factor authentication to secure your account.
        </FieldDescription>
        {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
      </FieldContent>
      <Switch
        id="form-rhf-switch-twoFactor"
        name={field.name}
        checked={field.value}
        onCheckedChange={field.onChange}
        aria-invalid={fieldState.invalid}
      />
    </Field>
  )}
/>

Complex Forms

複数のフィールドと検証を持つ、より複雑なフォームの例。

Component form-rhf-complex not found in registry.

フォームのリセット

form.reset() でフォームをデフォルト値にリセットします。

<Button type="button" variant="outline" onClick={() => form.reset()}>
  Reset
</Button>

配列フィールド

React Hook Form は動的な配列フィールドを管理する useFieldArray フックを提供しています。フィールドの動的な追加・削除に便利です。

Component form-rhf-array not found in registry.

useFieldArray の使い方

useFieldArray フックで配列フィールドを管理します。fieldsappendremove メソッドが提供されます。

form.tsx
import { useFieldArray, useForm } from "react-hook-form"
 
export function ExampleForm() {
  const form = useForm({
    // ... form config
  })
 
  const { fields, append, remove } = useFieldArray({
    control: form.control,
    name: "emails",
  })
}

配列フィールドの構造

配列フィールドは <FieldSet /> で包み、<FieldLegend /><FieldDescription /> を付けます。

form.tsx
<FieldSet className="gap-4">
  <FieldLegend variant="label">Email Addresses</FieldLegend>
  <FieldDescription>
    Add up to 5 email addresses where we can contact you.
  </FieldDescription>
  <FieldGroup className="gap-4">{/* Array items go here */}</FieldGroup>
</FieldSet>

配列アイテムの Controller パターン

fields 配列をマップして各アイテムに <Controller /> を使用します。キーには field.id を必ず使用してください

form.tsx
{
  fields.map((field, index) => (
    <Controller
      key={field.id}
      name={`emails.${index}.address`}
      control={form.control}
      render={({ field: controllerField, fieldState }) => (
        <Field orientation="horizontal" data-invalid={fieldState.invalid}>
          <FieldContent>
            <InputGroup>
              <InputGroupInput
                {...controllerField}
                id={`form-rhf-array-email-${index}`}
                aria-invalid={fieldState.invalid}
                placeholder="name@example.com"
                type="email"
                autoComplete="email"
              />
              {/* Remove button */}
            </InputGroup>
            {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
          </FieldContent>
        </Field>
      )}
    />
  ))
}

アイテムの追加

append メソッドで配列に新しいアイテムを追加します。

form.tsx
<Button
  type="button"
  variant="outline"
  size="sm"
  onClick={() => append({ address: "" })}
  disabled={fields.length >= 5}
>
  Add Email Address
</Button>

アイテムの削除

remove メソッドで配列からアイテムを削除します。条件付きで削除ボタンを追加します。

form.tsx
{
  fields.length > 1 && (
    <InputGroupAddon align="inline-end">
      <InputGroupButton
        type="button"
        variant="ghost"
        size="icon-xs"
        onClick={() => remove(index)}
        aria-label={`Remove email ${index + 1}`}
      >
        <XIcon />
      </InputGroupButton>
    </InputGroupAddon>
  )
}

配列の検証

Zod の array メソッドで配列フィールドを検証します。

form.tsx
const formSchema = z.object({
  emails: z
    .array(
      z.object({
        address: z.string().email("Enter a valid email address."),
      })
    )
    .min(1, "Add at least one email address.")
    .max(5, "You can add up to 5 email addresses."),
})