メインコンテンツへスキップ
友田 陽大
Reactフォーム実装
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 の二重検証、テストまで、実コードで「いつ・どう使うか」を判断できます。

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

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

友田

友田 陽大

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

お困りごとはありませんか?

設計から実装・運用まで、一人 × 生成AI で伴走します

この記事のような実装を、要件定義から本番運用まで一気通貫で。まずは30分の無料技術相談から、状況をお聞かせください。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

あわせて読みたい