Chuyển đến nội dung chính
友田 陽大
React forms
React
React Hook Form
Zod
TypeScript
Next.js
フォーム
型安全
パフォーマンス
フロントエンド

React Hook Form 完全ガイド【2026年最新・v7.80対応】— 型安全フォーム・再レンダリング設計・a11y・動的フィールド・Server Actions・テスト

公式ドキュメント最新版(React Hook Form v7.80 / @hookform/resolvers v5 / Zod 4)を一次情報に、型安全フォームの設計を本番品質で解説。zodResolver 連携、formState の Proxy 購読、watch / useWatch / useFormState による再レンダリング設計、FormProvider と型安全な再利用フィールド、useFieldArray の動的明細、async defaultValues、a11y、Server Actions の二重検証、テストまで、実コードで「いつ・どう使うか」を判断できます。

Published
Reading time
28 min read
Author
友田 陽大
Chia sẻ
Mục lục

この記事は React Hook Form(以下 RHF)公式ドキュメント——Get StarteduseFormregisterformStateuseControlleruseWatchuseFormStateuseFieldArrayhandleSubmitForm——の最新版を一次情報とし、さらに型定義のソース(react-hook-form v7.80 系)まで照合して書いています。目的は API の列挙ではなく、実務で「どの場面で・どう書くか」を自分で判断できるところまで踏み込むことです。

検証した版(2026年6月時点): react-hook-form v7 系(最新 7.80)、@hookform/resolvers v5 系、zod v4。v8 は出ていません。@hookform/resolvers v5 で Zod 4Standard Schema(Valibot / ArkType などを共通インターフェースで扱う仕組み)に正式対応しています。


0. 30秒で全体像:RHF の設計を貫く1本の軸

RHF を「便利なフォームライブラリ」と捉えると細部で迷います。貫く軸は1つ——**「状態の購読を、必要な場所に・必要な分だけ閉じ込める」**です。

  • 入力値の保持 … 非制御(ref)に寄せ、入力中はコンポーネントを再描画しない。
  • 検証zodResolver で Zod に委譲し、型・ルール・文言を1スキーマに集約する。
  • 状態の購読formState / watch / useWatch / useFormState は「読んだ場所が再描画される」。だからどこで読むかが設計判断になる。

この3点を意識するだけで、後述の落とし穴(Proxy 購読、watch の再描画爆発、Controller の取り違え)はほぼ回避できます。以降はこの軸を具体化していきます。


1. なぜ RHF なのか、そして「いつ使わないか」

1-1. 非制御 × 再レンダリング最小化

useState でフォームを作ると、1文字入力するたびにコンポーネント全体が再レンダリングされます。フィールドが数個なら無害ですが、数十項目の業務フォームでは入力のたびに全体が再描画され、体感のもたつきと無駄な計算を生みます。

RHF は**非制御コンポーネント(uncontrolled)**を主体に、ref で入力値を直接購読します。公式が掲げる設計思想は明快です——

  • 非制御コンポーネントとネイティブ HTML 入力を活用する
  • 不要な計算(再レンダリング)を避ける
  • 必要なときだけ再レンダリングを隔離する

結果として、入力中はほぼ再レンダリングが発生しません。検証のタイミングも mode で制御でき、コストと UX のバランスを取れます。

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";

const schema = z.object({
  name: z.string().min(2, { error: "2文字以上で入力してください" }),
  age: z.number({ error: "数値で入力してください" }).int().min(0),
});

type Schema = z.infer<typeof schema>;

function App() {
  const { register, handleSubmit } = useForm<Schema>({
    resolver: zodResolver(schema), // 検証は Zod に委譲
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))} noValidate>
      <input {...register("name")} />
      {/* 数値入力は valueAsNumber で number に変換 */}
      <input type="number" {...register("age", { valueAsNumber: true })} />
      <button>送信</button>
    </form>
  );
}

設計の起点: RHF は「フォーム状態」を、Zod は「検証ルールと型」を担います。スキーマ1つを**単一の正(Single Source of Truth)**にすれば、型・検証・メッセージが一元化されます(DRY)。Zod 側の書き方は Zod 4 実践ガイド を参照してください。

1-2. いつ RHF を使い、いつ使わないか(意思決定)

「フォーム=とりあえず RHF」は実は早計です。React 19 / Next.js では選択肢が増えました。要件で選びます。

状況推奨理由
数項目・JSなし送信を優先・即時検証UX不要素の form + Server Action(useActionState依存ゼロ。プログレッシブに動く
中〜大規模・即時/きめ細かい検証UX・動的フィールド・制御UIライブラリ統合React Hook Form再描画最小化と検証統合が効く本命
複数フレームワーク(Solid/Vue 等)も使う・型システム駆動を最重視TanStack Formフレームワーク非依存・型に厳格
Formik を使っている既存資産RHF へ移行Formik はメンテ停滞・制御主体で再描画が多い

判断の核は2つ——(1) 即時バリデーションの UX が要るか、(2) 再描画コストが問題になる規模か。両方 No なら素の form で十分。どちらかが Yes なら RHF が最短距離です。


2. useForm:中核の設定と戻り値

useForm はフォーム1つにつき1回呼び出し、すべての操作の起点になります。主要オプションを実務での効き目つきで挙げます。

const {
  register,     // ネイティブ入力を登録
  handleSubmit, // 送信ハンドラを生成
  control,      // Controller / useFieldArray / useWatch に渡す
  formState,    // errors / isDirty / isValid / isSubmitting ...(Proxy。後述)
  reset,        // フォーム全体をリセット
  resetField,   // 単一フィールドだけリセット
  setValue,     // プログラムから値を設定
  getValues,    // 現在値を取得(購読しない=再描画しない)
  watch,        // 値を購読(呼んだ場所が再描画される)
  setError,     // サーバーエラー等を手動設定
  setFocus,     // 任意フィールドにフォーカス
  trigger,      // 手動でバリデーション実行
  getFieldState,// 単一フィールドの状態を取得
} = useForm<Schema>({
  resolver: zodResolver(schema),
  defaultValues: { name: "", age: 0 }, // 重要:全フィールドの初期値を与える
  mode: "onTouched",     // 検証タイミング(下表)
  reValidateMode: "onChange", // 一度エラーになった後の再検証タイミング(既定 onChange)
  criteriaMode: "firstError", // "all" にすると1項目の全エラーを収集
  shouldFocusError: true,     // 送信失敗時に最初のエラー項目へ自動フォーカス(既定 true)
  delayError: 0,              // エラー表示を N ミリ秒遅延(連打時のチラつき抑制)
});

2-1. mode:検証タイミングの選択

modeいつ検証するか使いどころ
onSubmit(既定)送信時入力中の邪魔をしたくない一般フォーム
onBlurフォーカスを外したとき「入力し終えたら指摘」する自然なUX
onTouched初回は blur、以降は changeバランス型(おすすめの落としどころ)
onChange入力のたび即時フィードバック(再レンダリング増・要注意
allblur と change の両方最も厳格(コスト最大)

公式も onChange には「入力ごとに再レンダリングが走り、パフォーマンスに大きな影響が出ることがある」と明記しています。既定は onTouchedonBlur を推奨します。なお reValidateMode は「一度エラーが出た後」の再検証タイミングで、初回検証の mode とは別物です。

2-2. defaultValuesvalueserrors

  • defaultValues … 初期値。キャッシュされ、reset で更新します。isDirty / dirtyFields を正しく出すには全フィールドに与えること(差分の基準になるため)。undefined を初期値にしないこと。
  • values … リアクティブな値。外部データ(API 取得)で後から上書きしたいときに使います。values が変わると内部で reset 相当が走り再同期されます。
  • errors … 外部(サーバー)から渡すエラーをリアクティブに反映する入口。values と対になる「サーバー由来エラーの宣言的な反映」に使えます。
// 編集フォーム:API から取れたら自動で反映される
const { data } = useFetchUser(id);
useForm({
  defaultValues: { name: "", email: "" },
  values: data, // 取得後に上書き(リアクティブ)
});

2-3. defaultValues を非同期で読む(編集フォームのローディング)

defaultValues には async 関数も渡せます。「サーバーから取得した値を初期値にする」編集フォームの定石です。読み込み中は formState.isLoadingtrue になり、初期化が完了すると formState.isReadytrue になります——これでスケルトン表示まで宣言的に書けます。

const {
  register,
  formState: { isLoading, isReady },
} = useForm<UserForm>({
  // payload は不要なら省略可。Promise を返すだけ
  defaultValues: async () => {
    const res = await fetch(`/api/users/${id}`);
    return (await res.json()) as UserForm; // 取得値がそのまま初期値に
  },
  resolver: zodResolver(userSchema),
});

if (isLoading) return <FormSkeleton />; // 取得中はスケルトン
// isReady を使えば「内部の初期化完了」までを厳密に待てる

values と async defaultValues の使い分け: すでに親が取得済みのデータを「流し込む」だけなら values。フォーム自身に「初期値の取得責務」を持たせ、ローディング UI も内包したいなら async defaultValues。SRP の観点では、データ取得はフォーム外(Server Component や TanStack Query)に置き values で渡すほうが疎結合になりやすいです。


3. register:ネイティブ入力の登録

register("name"){ ref, name, onChange, onBlur } を返し、スプレッドで入力に展開します。ネストや配列もドット記法で表現できます。

<input {...register("name")} />            // { name: value }
<input {...register("address.city")} />     // { address: { city: value } }
<input {...register("items.0.title")} />    // { items: [{ title: value }] }

主なオプション:

  • valueAsNumber … 値を number に変換(数値入力の必須設定)
  • valueAsDateDate に変換
  • setValueAs … 任意の変換関数(valueAsNumber 等と併用不可)
  • disabled … 入力を無効化(値は undefined 扱いになり検証対象から外れる)
  • deps … この項目の検証時に連動して再検証するフィールド(例:確認用パスワード)
  • onChange / onBlur … RHF の登録に加えて自前のハンドラも走らせたいとき
// 「パスワード」を変えたら「確認用」も再検証する
<input type="password" {...register("password", { deps: ["passwordConfirm"] })} />

型安全の注意(公式ルール): 配列はドット記法のみ対応です。register("items.0.title") は ✅、register("items[0].title") は ❌。また register("test", {})register("test", undefined) でオプションを消すことはできません({ required: false } のように明示します)。valueAs* は組み込み検証より先に走るため、Zod 側は変換後の型(number 等)を検証する前提で書きます。


4. formState は Proxy——最大の落とし穴

formState には errors / isDirty / isValid / isValidating / isSubmitting / isSubmitSuccessful / isLoading / isReady / submitCount / touchedFields / dirtyFields / validatingFields / defaultValues / disabled が入ります。ここが RHF で最も間違えられるポイントです。

公式の言葉を借りると、formStateレンダリング性能のために Proxy でラップされており、**「購読していない状態は更新ロジックをスキップ」**します。つまり、事前に読み出して購読しないと、その値は更新されません

// ❌ formState.isValid を条件式の中で初めて参照している
//    → Proxy が購読しないため、isValid の変化でボタンが更新されない
return <button disabled={!formState.isDirty || !formState.isValid}>送信</button>;

// ✅ レンダリング前に分割代入して「購読」する
const { isDirty, isValid } = formState;
return <button disabled={!isDirty || !isValid}>送信</button>;

同様に useEffect で監視するなら、依存配列には**formState 全体**を入れます(formState.errors 単体ではバッチ更新の都合で発火しないことがある)。

useEffect(() => {
  if (formState.isSubmitSuccessful) reset();
}, [formState, reset]); // ✅ formState 全体を依存に

この「先に読む=購読する」を体に入れておくと、RHF の不可解な「状態が変わらない」バグの大半を回避できます。isValidmode 次第で評価タイミングが変わる点にも注意(onSubmit の場合、送信前は楽観的に true のことがある)。


5. 再レンダリングを「設計」する:watch / useWatch / useFormState / getValues

RHF を選ぶ最大の理由がパフォーマンスなのに、watch の使い方を誤ると再描画が爆発します。ここを設計できるかで実力が出ます。4つの読み取り手段の性格を押さえましょう。

手段購読するか再描画する場所使いどころ
getValues()しないしない送信時・イベントハンドラ内で値を読むだけ
watch("x")するuseForm を呼んだコンポーネント全体小規模。手軽だが巻き込みが大きい
useWatch({ name })するこのフックを呼んだ子だけ値の表示を小さな子に隔離する
useFormState({ control })する(formState のみ)このフックを呼んだ子だけ送信ボタンなど formState だけ要る箇所

要点は**「読む場所=再描画する場所」**。だから「値を映したい末端」だけを小さなコンポーネントに切り出し、そこで useWatch を呼ぶのが定石です。親(フォーム全体)は再描画しません。

import { useForm, useWatch, type Control } from "react-hook-form";

// ✅ 合計表示だけを子に隔離。明細を打っても再描画されるのは <Total> のみ
function Total({ control }: { control: Control<InvoiceForm> }) {
  const items = useWatch({ control, name: "items" });
  const total = items.reduce((sum, i) => sum + (i.price ?? 0) * (i.qty ?? 0), 0);
  return <output>{total.toLocaleString()} 円</output>;
}

function InvoiceForm() {
  const { register, control, handleSubmit } = useForm<InvoiceForm>({
    defaultValues: { items: [{ price: 0, qty: 1 }] },
  });
  return (
    <form onSubmit={handleSubmit(save)}>
      {/* ...明細入力... */}
      <Total control={control} /> {/* ここだけ再描画される */}
    </form>
  );
}

同様に、送信ボタンの活性だけ isValid / isDirty を見たいなら、ボタンを子に切り出して useFormState を使えば、親フォームは入力中まったく再描画しません。

import { useFormState } from "react-hook-form";

function SubmitButton({ control }: { control: Control<Schema> }) {
  const { isDirty, isValid, isSubmitting } = useFormState({ control });
  return (
    <button disabled={!isDirty || !isValid || isSubmitting}>
      {isSubmitting ? "送信中…" : "送信"}
    </button>
  );
}

アンチパターン: const values = watch();(引数なし=全フィールド購読)を useForm 直下で呼ぶと、1文字ごとにフォーム全体が再描画され、RHF を使う意味が半減します。「全体を見たい」ときも、表示する末端に useWatch を下ろすのが正解です。


6. Controller / useController:制御 UI ライブラリの統合

shadcn/ui・MUI・Ant Design・React-Select のような制御コンポーネントref で値を取れないため、register では繋がりません。ここで Controller(または useController)を使います。

import { useForm, Controller } from "react-hook-form";
import ReactDatePicker from "react-datepicker";

function App() {
  const { control, handleSubmit } = useForm<{ publishedAt: Date }>();

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <Controller
        control={control}
        name="publishedAt"
        render={({ field: { onChange, onBlur, value, ref }, fieldState }) => (
          <>
            <ReactDatePicker onChange={onChange} onBlur={onBlur} selected={value} ref={ref} />
            {fieldState.error && <p role="alert">{fieldState.error.message}</p>}
          </>
        )}
      />
      <button>送信</button>
    </form>
  );
}

field が提供する各プロパティの役割(公式):

プロパティ役割
onChange値を RHF に送り返す
onBlur入力に触れた(フォーカス/ブラー)ことを通知
value入力の現在値
refエラー時にフォーカスを当てる
nameフィールド名
disabledフォーム全体/個別の無効化状態

fieldState からは invalid / isDirty / isTouched / error が取れ、そのフィールド単独の状態を a11y 属性にそのまま流せます。

アンチパターン: registerControllerfield)を同じ入力に両方適用しないこと(<input {...field} {...register('x')} /> は ❌)。また制御入力では onChange(undefined) は不正で、null か空文字を使います。再利用可能な入力コンポーネントを作るなら、フックの useController が便利です(次節)。


7. FormProvider × useController:型安全な「再利用フィールド」を作る

実務のフォームは「同じ見た目のフィールドが何十個も並ぶ」もの。各フィールドに registerlabelaria-*・エラー表示をベタ書きすると、DRY も a11y も崩れます。FormProvider でフォーム文脈を配り、useController で1フィールドを部品化するのが正攻法です。型は FieldValuesPath<T> で安全に保ちます。

"use client";
import {
  useController,
  useFormContext,
  type FieldValues,
  type Path,
} from "react-hook-form";

type TextFieldProps<T extends FieldValues> = {
  name: Path<T>; // ✅ そのスキーマに存在するキーしか渡せない(型安全)
  label: string;
  type?: React.HTMLInputTypeAttribute;
};

// SRP:このコンポーネントの責務は「1つの入力+ラベル+エラー+a11y」だけ
export function TextField<T extends FieldValues>({
  name,
  label,
  type = "text",
}: TextFieldProps<T>) {
  const { control } = useFormContext<T>();
  const { field, fieldState } = useController<T>({ name, control });
  const errorId = `${name}-error`;

  return (
    <div className="field">
      <label htmlFor={name}>{label}</label>
      <input
        id={name}
        type={type}
        aria-invalid={fieldState.invalid}
        aria-describedby={fieldState.error ? errorId : undefined}
        {...field}
      />
      {fieldState.error && (
        <p id={errorId} role="alert">
          {fieldState.error.message}
        </p>
      )}
    </div>
  );
}

使う側はこれだけ。FormProvidermethods を配るので、子フィールドに control をプロップで渡し続ける必要がありません(バケツリレーの解消)。

"use client";
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

export function SignupForm() {
  const methods = useForm<Schema>({ resolver: zodResolver(schema) });

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onValid)} noValidate>
        <TextField<Schema> name="email" label="メールアドレス" type="email" />
        <TextField<Schema> name="name" label="氏名" />
        <SubmitButton control={methods.control} />
      </form>
    </FormProvider>
  );
}

この設計が同時に解く課題:

  • DRY … a11y 属性・エラー表示・id 連番のロジックを1か所に集約。
  • ETC(変えやすさ) … デザインシステム差し替えは TextField の中だけ。
  • パフォーマンスuseControllerそのフィールドの変化でだけ再描画されるため、入力が他フィールドを巻き込みません(§5 の隔離が部品レベルで効く)。
  • 型安全namePath<T> なので、存在しないキーや打ち間違いはコンパイルエラー。

8. useFieldArray:動的フィールド(明細・タグ・複数連絡先)

「請求明細を行で追加」「タグを可変個」——配列項目の動的な追加・削除は useFieldArray の出番です。

import { useForm, useFieldArray } from "react-hook-form";

function InvoiceForm() {
  const { register, control, handleSubmit } = useForm<{
    items: { name: string; price: number }[];
  }>({ defaultValues: { items: [{ name: "", price: 0 }] } });

  const { fields, append, remove } = useFieldArray({ control, name: "items" });

  return (
    <form onSubmit={handleSubmit((d) => console.log(d))}>
      {fields.map((field, index) => (
        // ✅ key は必ず field.id(index は不可)
        <div key={field.id}>
          <input {...register(`items.${index}.name`)} />
          <input type="number" {...register(`items.${index}.price`, { valueAsNumber: true })} />
          <button type="button" onClick={() => remove(index)}>削除</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ name: "", price: 0 })}>明細を追加</button>
      <button>送信</button>
    </form>
  );
}

公式が強調する要点:

  • key には必ず field.id を使う(index は再レンダリングでフィールドが壊れる原因)。
  • append / prepend / insert に渡す値は部分でなく完全な形であること(append({}) は ❌)。
  • 同じ name で複数の useFieldArray を使わない。useFieldArray では shouldUnregister: true は非対応。

操作 API は append / prepend / insert / remove / move / swap / update / replace が揃っています。明細の合計をリアルタイム表示するなら、§5 のとおり useWatch を末端の <Total> に下ろして再描画を隔離してください。


9. handleSubmit:成功・失敗・サーバーエラーと「二重送信」対策

handleSubmit(onValid, onInvalid?) は、検証通過時 onValid(data)検証失敗時 onInvalid(errors) を呼びます。onValidasync 可。

const {
  handleSubmit,
  setError,
  formState: { isSubmitting, errors },
} = useForm<Schema>({ resolver: zodResolver(schema) });

const onValid = async (data: Schema) => {
  try {
    await api.submit(data);
  } catch (e) {
    // 重要:handleSubmit は onValid 内の例外を握りつぶさない。必ず自分で捕捉する
    // サーバーエラーはフォーム全体エラーとして root に積む
    setError("root.server", {
      message: "送信に失敗しました。時間をおいて再試行してください。",
    });
  }
};

return (
  <form onSubmit={handleSubmit(onValid, (errs) => console.log(errs))} noValidate>
    {/* ...入力... */}
    {errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
    {/* ✅ 送信中はボタンを無効化=二重送信を構造的に防ぐ(冪等性の入口) */}
    <button disabled={isSubmitting}>{isSubmitting ? "送信中…" : "送信"}</button>
  </form>
);

公式の注意:handleSubmitonValid 内のエラーを飲み込まないため、API 呼び出しは自分で try/catch し、setError でサーバー側エラーを登録します(これにより formState.isSubmitSuccessfulfalse のままになります)。

信頼性の観点で2つ徹底します:

  1. 二重送信防止disabled={isSubmitting} で送信中はボタンを止める。ネットワーク遅延中の連打を UI で封じ、サーバー側でも冪等キーで二重実行を弾く(決済では必須。冪等性の設計 参照)。
  2. フィールド単位のサーバーエラー … 「このメールは既に使われています」のような項目別エラーは setError("email", { message }) で該当フィールドに戻し、shouldFocus でそこへフォーカスさせる。

送信成功後にサーバー状態(一覧など)を更新したいケースは、フォーム送信を TanStack Query の Mutation に繋ぐのが定石です。楽観的更新やキャッシュ無効化の設計は TanStack Query v5 実践ガイド を参照してください。


10. 入力型 ≠ 出力型:.transform() を第3ジェネリクスで型安全に

Zod 4 では .transform()z.coerce入力型と出力型がズレます(Zod 4 実践ガイド参照)。RHF はこれを第3ジェネリクスで受け止めます。useForm<TFieldValues, TContext, TTransformedValues> という3引数が、まさにこの「変換前/変換後」を表現します。

import * as z from "zod";

const schema = z.object({
  // フォーム上は文字列、送信後は number にしたい
  price: z.string().transform((s) => Number.parseInt(s, 10)),
});

type FormInput = z.input<typeof schema>;   // { price: string }  ← register が扱う型
type FormOutput = z.output<typeof schema>;  // { price: number } ← handleSubmit が受け取る型

const { register, handleSubmit } = useForm<FormInput, unknown, FormOutput>({
  resolver: zodResolver(schema),
});

handleSubmit((data) => {
  data.price; // number として型がつく(変換後)
});
  • 第1ジェネリクス … 入力型(register / watch が扱う、変換前のフォーム値)
  • 第3ジェネリクス … 出力型(handleSubmit が受け取る、変換後の確定値)

この分離により、「画面は文字列・ロジックは数値」を型の嘘なしで実現できます。なお zodResolver は第2引数で挙動を調整でき、zodResolver(schema, undefined, { raw: true }) とすると変換前の生値を返します(フォームには生値を保持し、変換は送信後に別途行いたい場合)。Valibot / ArkType など他ライブラリでも、standardSchemaResolver を使えば同じ型分離の恩恵を受けられます。


11. アクセシビリティ(a11y):実装すべき3点セット

フォームの a11y は「弾く」だけでなく「何をどう直すかを支援技術にも伝える」ことです。公式例は aria-invalidrole="alert" を用います。実務では**aria-describedby でエラー文を入力に関連付ける**のが理想です(§7 の TextField はこれを内蔵しています)。

const { register, formState: { errors } } = useForm<Schema>({ resolver: zodResolver(schema) });

<label htmlFor="email">メールアドレス</label>
<input
  id="email"
  type="email"
  {...register("email")}
  aria-invalid={errors.email ? "true" : "false"}
  aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
  <p id="email-error" role="alert">
    {errors.email.message}
  </p>
)}

3点セット:

  1. aria-invalid … エラー状態を支援技術へ伝える。
  2. aria-describedby … エラー文(id 付き)を入力に紐づけ、フォーカス時に読み上げさせる。
  3. role="alert" … エラー出現を即時に通知。

加えて、shouldFocusError(既定 true)が送信失敗時に最初のエラー項目へ自動フォーカスします。フォームには noValidate を付け、検証 UI を RHF/Zod に一本化するとメッセージが一貫します。a11y を WCAG 2.2 まで含めて体系化したい場合は React/Next.js アクセシビリティ実装ガイド を参照してください。


12. Next.js App Router:"use client" / Server Actions / <Form> / プログレッシブ

12-1. 置き場所の鉄則

useForm / register / ControllerReact フックなので、必ず "use client" のクライアントコンポーネントに置きます(page.tsx 直下に書かない)。ページ(Server Component)はデータ取得とレイアウトに専念し、フォーム本体を子のクライアントコンポーネントに分離します。

12-2. Server Actions との「二重検証」が本番の型

Server Actions と併用する場合の鉄則は——クライアント側で zodResolver による即時 UX 検証を行いつつ、サーバー側でも同じ Zod スキーマで再検証することです(クライアントは信用しない)。スキーマは1つを共有し、両端で適用するのが安全と DRY の両立です。

// schema.ts —— クライアント/サーバー共有の単一スキーマ
export const contactSchema = z.object({
  email: z.email({ error: "メールアドレスを正しく入力してください" }),
  message: z.string().min(10, { error: "10文字以上で入力してください" }),
});
export type Contact = z.infer<typeof contactSchema>;
// actions.ts —— サーバーでも必ず再検証する
"use server";
import * as z from "zod";
import { contactSchema } from "./schema";

export async function submitContact(input: unknown) {
  const parsed = contactSchema.safeParse(input); // 信頼境界はここ
  if (!parsed.success) {
    // フィールド別エラーを構造化して返す
    return { ok: false as const, errors: z.flattenError(parsed.error).fieldErrors };
  }
  await db.contacts.insert(parsed.data);
  return { ok: true as const };
}
// contact-form.tsx —— クライアントは即時UX、サーバー結果を setError に反映
"use client";
const onValid = async (data: Contact) => {
  const res = await submitContact(data);
  if (!res.ok) {
    // サーバーが返した項目別エラーを RHF に流し込む(信頼の源はサーバー)
    for (const [name, messages] of Object.entries(res.errors)) {
      if (messages?.[0]) setError(name as keyof Contact, { message: messages[0] });
    }
  }
};

サーバー側のバリデーション・整形の作法は Zod 4 実践ガイド に詳述しています。

12-3. RHF の <Form> コンポーネントとプログレッシブ・エンハンスメント

RHF v7 には送信を一手に引き受ける <Form> コンポーネントがあります。action を指定すると検証 → fetch で POSTまで面倒を見て、結果に応じて onSuccess / onError を呼びます。action を省略すればネイティブ送信になり、JS が無効でも動く土台になります。

import { useForm, Form } from "react-hook-form";

function Newsletter() {
  const { control, register, formState: { errors } } = useForm({
    resolver: zodResolver(contactSchema),
  });

  return (
    <Form
      action="/api/contact" // 指定で fetch POST省略でネイティブ送信
      method="post"
      control={control}
      onSuccess={() => {/* 2xx */}}
      onError={() => setError("root.server", { message: "送信に失敗しました" })}
    >
      <label htmlFor="email">メール</label>
      <input id="email" {...register("email")} aria-invalid={!!errors.email} />
      <button>登録</button>
    </Form>
  );
}

さらに useForm({ progressive: true })shouldUseNativeValidation: true を使うと、required / min などのネイティブ検証属性が出力され、JS 到達前でもブラウザ標準の検証が効きます。「最初の描画から壊れていない」フォームを目指す Next.js のサーバー中心設計と相性が良い選択肢です。


13. テスト容易性:フォームのユニットテスト

RHF + Zod は検証を1スキーマに集約するため、**テストは「描画 → 操作 → 表示・送信を検証」**の形に素直に落ちます。@testing-library/react@testing-library/user-event で、ロール/ラベル経由に操作すると a11y まで同時に検証できます(getByLabelText が通る=ラベルが正しく結びついている証拠)。

// contact-form.test.tsx (jsdom 環境)
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { ContactForm } from "./contact-form";

describe("ContactForm", () => {
  it("空送信は onSubmit を呼ばず、エラーを読み上げ領域に出す", async () => {
    const onSubmit = vi.fn();
    render(<ContactForm onSubmit={onSubmit} />);

    await userEvent.click(screen.getByRole("button", { name: "送信" }));

    expect(await screen.findByRole("alert")).toHaveTextContent(
      "メールアドレスを正しく入力してください",
    );
    expect(onSubmit).not.toHaveBeenCalled();
  });

  it("妥当な入力で整形済みデータを onSubmit に渡す", async () => {
    const onSubmit = vi.fn();
    render(<ContactForm onSubmit={onSubmit} />);

    await userEvent.type(screen.getByLabelText("メールアドレス"), "a@example.com");
    await userEvent.type(screen.getByLabelText("お問い合わせ"), "詳しい資料が欲しいです");
    await userEvent.click(screen.getByRole("button", { name: "送信" }));

    await waitFor(() =>
      expect(onSubmit).toHaveBeenCalledWith({
        email: "a@example.com",
        message: "詳しい資料が欲しいです",
      }),
    );
  });
});

ポイントは**「実装の内部状態をテストしない」**こと。formState.isValid を直接読むのではなく、ユーザーが見るもの(エラーメッセージ・送信の発火)を検証します。これにより、RHF の内部や mode を変えてもテストが壊れにくくなります(リファクタ耐性)。E2E まで含めた設計は Playwright E2E 実践ガイド を参照してください。


14. 実務で効くその他 API 早見表

API何をするか典型的な使いどころ
reset(values?, options?)フォーム全体を初期化/指定値で再設定送信成功後・編集キャンセル。keepDirtyValues 等で保持範囲を制御
resetField(name)単一フィールドだけ初期化「この項目だけ元に戻す」
setFocus(name)任意フィールドへフォーカスステップ移動・エラー誘導
getFieldState(name)単一フィールドの状態を取得個別の invalid / isDirty 判定
trigger(name?)手動で検証を発火ウィザードの「次へ」で当該ステップだけ検証
unregister(name)登録解除条件付き表示フィールドの後始末
disabled(useForm)フォーム全体を無効化送信中に全入力をロック
createFormControl()コンポーネント外でフォーム制御を生成描画に依存せず状態を読む・ウィザード横断

createFormControl は少し上級です。useForm の内部実装を外に取り出すもので、生成した formControluseForm({ formControl }) に渡せば、コンポーネントの再描画に依存せずフォーム状態を読み書きできます。複数ステップにまたがる巨大ウィザードや、フォーム外のロジックから状態を監視したいケースで効きます。まずは FormProvider で足りないか検討し、本当に必要なときだけ使ってください(YAGNI)。


15. ベストプラクティス&アンチパターン

やるべきこと(Do)避けるべきこと(Don't)
検証は zodResolver で Zod に委譲(型・検証・文言を一元化)RHF 組み込みルールと resolver を混在させる
formState は分割代入で先に読んで購読条件式の中で初めて formState.isValid を参照
defaultValues は全フィールド分を与える一部だけ/undefined を初期値にして isDirty が壊れる
値の表示は末端で useWatch、ボタンは useFormStateuseForm 直下で引数なし watch() し全体を再描画
制御 UI は ControlleruseController、ネイティブは register制御コンポーネントを register で繋ごうとする
再利用フィールドは FormProviderPath<T> で型安全に部品化各フィールドに registeraria-* をベタ書きで重複
useFieldArraykeyfield.idkey={index} でフィールドが壊れる
.transform() は第3ジェネリクスで入出力型を分離変換後の型を as で握りつぶす
aria-invalidaria-describedbyrole="alert"エラー文を視覚だけに頼る
送信中は disabledisSubmitting で二重送信を封じる連打でリクエストが多重発火する
サーバーエラーは setError、API は自分で try/catchhandleSubmit がエラーを処理してくれると誤解
クライアント/サーバーで同じ Zod スキーマを共有クライアント検証だけでサーバーを素通し

16. FAQ(よくある質問)

Q. なぜ useState 直書きより React Hook Form なのですか? A. RHF は非制御主体で、入力中の再レンダリングをほぼ排除します。大規模フォームでの体感速度・コードの簡潔さ・検証統合のすべてで有利です。逆に数項目なら素の form + Server Action で十分なこともあります(§1-2)。

Q. registerController の使い分けは? A. ネイティブ HTML 入力(input / select / textarea)は registerref で値を取れない制御 UI ライブラリ(shadcn/MUI/React-Select 等)は ControlleruseController です。

Q. isValid でボタンの活性を切り替えると動きません。 A. formState が Proxy だからです。const { isValid } = formState; のようにレンダリング前に分割代入して購読してください。条件式の中で初めて参照すると更新されません(§4)。

Q. 入力が重い・1文字ごとにフォーム全体が再描画されます。 A. watch() をフォーム直下で呼んでいないか確認を。表示は末端の useWatch、ボタンは useFormState に隔離し、再利用フィールドは useController で部品化すると、再描画がそのフィールドに閉じます(§5・§7)。

Q. 編集フォームで API から初期値を入れたい。 A. 親が取得済みなら values で流し込み、フォーム自身に取得責務を持たせるなら async defaultValuesisLoadingisReady でスケルトン表示します(§2-3)。

Q. Server Components で useForm が使えません。 A. フックなので "use client" 必須です。サーバー処理は Server Action/ルートハンドラに分け、同じ Zod スキーマで再検証します(§12)。

Q. Yup や valibot ではなく Zod を選ぶ理由は? A. Zod は TypeScript ファーストで型推論が最も自然、エコシステムも厚い(@hookform/resolversdrizzle-zod、AI SDK の構造化出力など)。valibot/ArkType を使いたい場合も standardSchemaResolver で同様に繋げます。詳細は Zod 4 実践ガイド

Q. 数値や日付がうまく取れません。 A. register("age", { valueAsNumber: true }){ valueAsDate: true } を指定してください。指定しないと文字列のまま渡ります(§3)。


まとめ:フォームは「状態 × 検証 × アクセシビリティ」の合流点

React Hook Form を使いこなす鍵は、フォームを「入力欄の集合」ではなく「状態・検証・アクセシビリティが合流する設計対象」として捉えることです。本記事の柱を振り返ると——

  1. 非制御 × 再レンダリング最小化で、軽快な大規模フォームを作る。
  2. zodResolver で Zod に検証を委譲し、型・検証・文言を単一スキーマに集約する。
  3. formState の Proxy 購読を理解し、「先に読む」を徹底する。
  4. watch / useWatch / useFormState で再描画を「読む場所」に閉じ込め、FormProvideruseController で型安全な再利用フィールドを作る。
  5. Controller / useFieldArray で制御 UI と動的フィールドを扱い、a11y 3点セット二重送信防止クライアント/サーバー二重検証で本番品質に仕上げる。

これらは小手先のテクニックではなく、ユーザー体験・型安全性・保守性・パフォーマンスを同時に引き上げる「設計の選択」です。正しく適用すれば、入力者の体験(的確な指摘)と開発者の体験(壊れにくさ)の両方が底上げされます。

業務システムの複雑なフォーム設計・既存フォームの 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