メインコンテンツへスキップ
友田 陽大
フロントエンド
React
React Hook Form
Zod
TypeScript
フォーム
型安全
パフォーマンス
フロントエンド

React Hook Form × Zod 実践ガイド【2026年最新・v7対応】— 型安全フォーム・再レンダリング最小化・a11y・動的フィールド・Server Actions

公式ドキュメント最新版(React Hook Form v7 / @hookform/resolvers v5 / Zod 4)に忠実な実践ガイド。zodResolver 連携、再レンダリングを最小化する非制御アーキテクチャと formState の Proxy 購読、Controller による制御コンポーネント統合、useFieldArray の動的フィールド、a11y、Server Actions まで、本番品質のコード例で「いつ・どう使うか」を解説します。

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

この記事は React Hook Form 公式ドキュメント(Get StarteduseFormregisterformStateuseControlleruseFieldArrayhandleSubmit)の最新版を一次情報として、実務で「どの場面で・どう書くか」を判断できるところまで踏み込みます。


1. なぜ React Hook Form なのか(非制御 × 再レンダリング最小化)

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: "数値で入力してください" }),
});

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))}>
      <input {...register("name")} />
      {/* 数値入力は valueAsNumber で number に変換 */}
      <input type="number" {...register("age", { valueAsNumber: true })} />
      <input type="submit" />
    </form>
  );
}

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


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

useForm はフォーム1つにつき1回呼び出し、すべての操作の起点になります。

const {
  register, // ネイティブ入力を登録
  handleSubmit, // 送信ハンドラを生成
  control, // Controller / useFieldArray に渡す
  formState, // errors / isDirty / isValid / isSubmitting ...
  reset, // フォーム全体をリセット
  setValue, // プログラムから値を設定
  getValues, // 現在値を取得(購読しない)
  watch, // 値を購読(再レンダリングを伴う)
  setError, // サーバーエラー等を手動設定
  trigger, // 手動でバリデーション実行
} = useForm<Schema>({
  resolver: zodResolver(schema),
  defaultValues: { name: "", age: 0 }, // 重要:全フィールドの初期値を与える
  mode: "onBlur", // 検証タイミング(下表)
});

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

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

公式も onChange には「入力ごとに再レンダリングが走り、パフォーマンスに大きな影響が出ることがある」と明記しています。既定は onTouchedonBlur を推奨します。

2-2. defaultValuesvalues の違い

  • defaultValues … 初期値。キャッシュされ、reset で更新します。isDirty / dirtyFields を正しく出すには全フィールドに与えること(差分の基準になるため)。undefined を初期値にしないこと。
  • values … リアクティブな値。外部データ(API 取得)で後から上書きしたいときに使います。
// 編集フォーム:API から取れたら自動で反映される
const { data } = useFetchUser(id);
useForm({
  defaultValues: { name: "", email: "" },
  values: data, // 取得後に上書き
});

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 … 任意の変換関数
  • disabled … 入力を無効化(値は undefined になる)
  • deps … 連動して再検証するフィールド

型安全の注意(公式ルール): 配列はドット記法のみ対応です。register("items.0.title") は ✅、register("items[0].title") は ❌。また register("test", {})register("test", undefined) でオプションを消すことはできません({ required: false } のように明示します)。


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

formState には errors / isDirty / isValid / isSubmitting / isSubmitSuccessful / touchedFields / dirtyFields / isValidating などが入ります。ここが 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 の不可解な「状態が変わらない」バグの大半を回避できます。


5. 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>}
          </>
        )}
      />
      <input type="submit" />
    </form>
  );
}

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

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

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


6. 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>
      <input type="submit" />
    </form>
  );
}

公式が強調する要点:

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

操作 API は append / prepend / insert / remove / move / swap / update / replace が揃っています。


7. handleSubmit:成功・失敗とサーバーエラー

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

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

<form onSubmit={handleSubmit(onValid, (errors) => console.log(errors))} noValidate>

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

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


8. Zod の .transform() を型安全に扱う(入力型 ≠ 出力型)

Zod 4 では .transform()z.coerce入力型と出力型がズレます(Zod 4 実践ガイド参照)。RHF はこれを第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 が受け取る、変換後の確定値)

この分離により、「画面は文字列・ロジックは数値」を型の嘘なしで実現できます。


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

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

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 に一本化するとメッセージが一貫します。


10. Next.js App Router での注意("use client" / Server Actions)

useForm / register / ControllerReact フックなので、必ず "use client" のクライアントコンポーネントに置きます(page.tsx 直下に書かない)。

Server Actions と併用する場合は、クライアント側で zodResolver による即時 UX 検証を行いつつ、サーバー側でも同じ Zod スキーマで再検証します(クライアントは信用しない)。スキーマは1つを共有し、両端で適用するのが安全と DRY の両立です。サーバー側検証・整形の作法は Zod 4 実践ガイド に詳述しています。


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

やるべきこと(Do)避けるべきこと(Don't)
検証は zodResolver で Zod に委譲(型・検証・文言を一元化)RHF 組み込みルールと resolver を混在させる
formState は分割代入で先に読んで購読条件式の中で初めて formState.isValid を参照
defaultValues は全フィールド分を与える一部だけ/undefined を初期値にして isDirty が壊れる
制御 UI は Controller、ネイティブは register制御コンポーネントを register で繋ごうとする
useFieldArraykeyfield.idkey={index} でフィールドが壊れる
.transform() は第3ジェネリクスで入出力型を分離変換後の型を as で握りつぶす
aria-invalid + aria-describedby + role="alert"エラー文を視覚だけに頼る
サーバーエラーは setError、API は自分で try/catchhandleSubmit がエラーを処理してくれると誤解
クライアント/サーバーで同じ Zod スキーマを共有クライアント検証だけでサーバーを素通し

12. FAQ(よくある質問)

Q. なぜ useState 直書きより React Hook Form なのですか? A. RHF は非制御主体で、入力中の再レンダリングをほぼ排除します。大規模フォームでの体感速度・コードの簡潔さ・検証統合のすべてで有利です。

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

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

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

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

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


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

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

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

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

業務システムの複雑なフォーム設計・既存フォームの a11y/型安全化・パフォーマンス改善が必要な場合は、お気軽にご相談ください。 下記の事例では、業界の基幹業務を支える B2B SaaS を、型安全・保守性・ユーザビリティを重視して設計・実装した過程を紹介しています。

友田

友田 陽大

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

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通業界のDXを実現したB2BサブスクリプションSaaS

ケーススタディを見る