React Hook Formの使い方 — Zodバリデーションからサーバーサイド活用まで

React Hook Formは、Reactアプリケーションで高パフォーマンスなフォームを構築するためのライブラリです。非制御コンポーネントベースの設計により、入力のたびに再レンダリングが走る従来のアプローチと比べて、描画コストを大幅に削減できます。

この記事では、React Hook Formの基本的な使い方から、Zodによるバリデーション連携、さらにZodスキーマをサーバーサイドやOpenAPI定義に活用する方法まで解説します。

公式サイト
https://react-hook-form.com

目次

React Hook Form

React Hook Formが再レンダリングを抑える仕組み

通常、Reactでフォームを扱う場合はuseStateで入力値を管理します。この方法では、ユーザーが1文字入力するたびにstateが更新され、コンポーネントが再レンダリングされます。

React Hook Formはこのアプローチを取りません。入力中はrefを通じてDOMから直接値を参照し、フォーム送信時にまとめて値を取得します。これにより、入力中の不要な再レンダリングを回避できます。

参考
React Hook Formは非制御コンポーネントからどうやって変更を検知しているのか – commmune Engineer Blog

途中で値を取得したい場合はwatch関数を使うことで対応可能です。

基本の3機能
register・handleSubmit・formState

register、handleSubmit,、formStateこの3つはreact-hook-formで最も基本的で頻繁に使用される機能です。

register

inputやtextareaなどのフォーム要素をreact-hook-formに「登録」して、値の管理バリデーションを任せる機能

handleSubmit

フォームで下記のように書くとonSubmit()をコールする前にバリデーションをして、OKであれば実行します

<form onSubmit={handleSubmit(onSubmit)}>

onSubmit={…} ← これはHTML属性
onSubmit ← これは自分で定義した関数名

名前が同じで紛らわしいですが、たまたま慣習的に同じ名前を使っているだけです。

handleSubmit(onSubmit)

  1. フォーム送信イベントをキャッチ
  2. event.preventDefault() を実行(ページリロード防止)
  3. 全フィールドのバリデーション実行
  4. エラーがあれば → onSubmitは呼ばれない
  5. エラーがなければ → onSubmit(data) を実行

formState

フォームの現在の状態(エラー、送信中、タッチ済みなど)を取得できるオブジェクトです。

よく使うプロパティ

const { 
  errors,        // バリデーションエラー
  isValid,       // 全フィールドが有効か
  isSubmitting,  // 送信処理中か
  isDirty,       // 初期値から変更されたか
  isSubmitted,   // 送信が完了したか
  touchedFields  // フォーカスされたフィールド
} = formState;

Zodとは —

Zodとはデータのバリデーションと型定義を同時に行えるライブラリ です。

そもそもTypeScriptの型注釈について

TypeScriptの型注釈は「書けば書くほど良い」わけではなく、推論に任せられるところは任せて、境界には必ず書くのが基本方針です。整理します。

ただTypeScriptの型の使用に慣れていない人は

  • プリミティブは推論でOK
  • オブジェクトやユニオンとかは注釈
  • かきまくって→必要あるものに絞る

型推論とは、プログラマが型を明示的に書かなくても、コンパイラが自動的に型を判定してくれる機能

↓エディターでホバーすると型の確認できます(これは型推論ではなく普通に明示的にFCと型指定されてます)

原則
境界には書く、内側は推論に任せる

「境界(boundary)」とは、自分のコードと外の世界(他人・他モジュール・ネットワーク・ユーザー入力)が接するところです。

ここは推論が効かない、または効いても信用できないので明示します。

逆に、関数の内側のローカル変数などは推論に任せた方がノイズが減って読みやすくなります。

  • 関数の引数は常に書く
    引数は呼び出し側から何が来るか分からないので推論できません。必須です。
  • APIリクエスト/レスポンス
    必ず書く、しかも実行時検証とセットで ここが一番重要です。
サーバー 何を返すか不明 .json() 戻り値は any 型注釈は嘘になり得る zod.parse() 実行時に検証 型が保証される 型定義 + 実行時バリデーションをセットにする

なので、APIの境界では zod / valibot / arktype などで実行時バリデーションをして、その結果から型を導出するのが現代的なベストプラクティスです。

推論できる? No Yes 型を書く 関数の引数 / Props その推論は信用できる? No Yes 型 + 実行時検証 APIレスポンス / フォーム入力 書かない ローカル変数 3つに分類すれば、ほぼすべてのケースに対応できる

Zodの基本的な使用方法 parse()

z.object() でスキーマを作り、parse() でデータを検証します。
エラーがある場合は例外を投げ、正しい場合のみ安全に型付きデータとして返します。

Zodは、データのバリデーションとTypeScriptの型定義を1つのスキーマで同時に行えるライブラリです。

z.object()でスキーマを定義し、parse()でデータを検証します。

データが不正な場合は例外がスローされ、正しい場合は型付きデータとして返されます。

import { z } from "zod";

const userSchema = z.object({
  name: z.string().min(1, "名前は必須です"),
  age: z.number().min(0, "0以上で入力してください"),
});

z.infer<typeof schema>

z.infer<typeof schema>を使えば、スキーマ定義から自動的にTypeScript型を導出できます。型を別途interfaceで定義する必要がなくなり、型定義の二重管理を防げます。

// スキーマからTypeScript型を導出
type User = z.infer<typeof userSchema>;
// → { name: string; age: number }

interfaceとZodの使い分け

interfaceとZodは競合するものではなく、役割が異なります。

  • interface / type — コンパイル時の型チェック。JavaScriptにトランスパイルされた後は存在しない
  • Zod — ランタイムのバリデーション。実行時にデータが期待した形かを検証する

interface / typeで十分なケース
アプリ内部で完結するデータ。関数の引数、コンポーネントのprops、内部のstateなど。
コンパイル時の型チェックだけで安全性を担保できます。

Zodを使うべきケース
外部から入ってくるデータ。APIレスポンス、フォーム入力、環境変数、JSONファイル読み込み、URLパラメータなど。これらは実行時まで中身が分からないため、ランタイムでの検証が必要です。

内部のあらゆる型にZodを使うのはアンチパターンです。バリデーションが不要な場所にZodを導入すると、スキーマ定義の冗長化とバンドルサイズの増加というコストだけが残ります。

役割が違うので、競合するものではなく併用するものです。

interface / type — コンパイル時の型チェック。ランタイムには存在しない。

Zod — ランタイムのバリデーション。
外部から来るデータが期待通りの形かを実行時に検証する。z.infer<typeof schema> で型も導出できるので、型定義の二重管理を避けられる。

  • アプリ内部だけで完結するデータ(関数の引数、コンポーネントのprops、内部のstate)→ interface / type で十分。ランタイムチェックは不要。
  • 外部境界を越えるデータ(APIレスポンス、フォーム入力、環境変数、JSON読み込み、URLパラメータなど)→ Zodでスキーマを定義し、z.infer で型を導出する。型とバリデーションを一箇所にまとめられる。

zodResolverでReact hook formとZodを連携

React Hook Form がフォーム送信時に呼び出す「バリデーション処理」を、
Zod のスキーマを使って実行できるようにする関数 です。

React Hook Formのみ(register内でバリデーション定義)

export function RHFOnlyForm() {
  const { register, handleSubmit, formState: { errors } } = useForm();

  const onSubmit = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register("email", {
          required: "メールアドレスは必須です",
          pattern: {
            value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
            message: "有効なメールアドレスを入力してください",
          },
        })}
      />
      {errors.email && <p>{errors.email.message}</p>}
      <button type="submit">送信</button>
    </form>
  );
}

React Hook Form + Zod

const schema = z.object({
  email: z.string().nonempty("メールアドレスは必須です").email("有効なメールアドレスを入力してください"),
});

export function RHFZodForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
  });

  const onSubmit = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}
      <button type="submit">送信</button>
    </form>
  );
}

「Zod」を使用したスキーマの例
https://ics.media/entry/240611

Zodのサーバーサイド活用 — フォームだけではない使い道

Zodはフロントエンドのフォームバリデーションで使われるイメージが強いですが、実際にはサーバーサイドでも広く活用されています。外部から入ってくるデータを検証するという本質は、フロントもバックエンドも同じだからです。

サーバーサイドでの主な用途

  • APIリクエスト/レスポンスのスキーマ定義とバリデーション
  • 環境変数の型安全な読み込み
  • データベースから取得したデータの検証
  • 外部APIレスポンスの型保証

ZodスキーマからOpenAPI仕様を自動生成する

@asteasolutions/zod-to-openapiを使うと、Zodスキーマから直接OpenAPI(Swagger)仕様を生成できます。APIのスキーマ定義とバリデーションロジックを一箇所にまとめ、ドキュメントとの乖離を防げます。

さらに@redocly/cliと組み合わせれば、生成したOpenAPI仕様からAPIドキュメントのビルドやリンティングも自動化できます。

npm install @asteasolutions/zod-to-openapi @redocly/cli

以下は、ZodスキーマにOpenAPIメタデータを付与し、ルート定義まで行う実装例です。

import { extendZodWithOpenApi, OpenAPIRegistry, OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
import { z } from "zod";

// ZodにOpenAPI拡張を追加
extendZodWithOpenApi(z);

// --- スキーマ定義 ---
const UserSchema = z
  .object({
    id: z.string().uuid(),
    name: z.string().min(1, "名前は必須です"),
    email: z.string().email("有効なメールアドレスを入力してください"),
  })
  .openapi("User");

const CreateUserSchema = UserSchema.omit({ id: true }).openapi("CreateUserRequest");

const ErrorSchema = z
  .object({
    code: z.number(),
    message: z.string(),
  })
  .openapi("ErrorResponse");

// --- レジストリにルートを登録 ---
const registry = new OpenAPIRegistry();

registry.registerPath({
  method: "get",
  path: "/users",
  summary: "ユーザー一覧取得",
  request: {
    query: z.object({
      page: z.coerce.number().int().min(1).default(1).openapi({ example: 1 }),
      limit: z.coerce.number().int().min(1).max(100).default(20).openapi({ example: 20 }),
    }),
  },
  responses: {
    200: {
      description: "成功",
      content: { "application/json": { schema: z.array(UserSchema) } },
    },
    500: {
      description: "サーバーエラー",
      content: { "application/json": { schema: ErrorSchema } },
    },
  },
});

registry.registerPath({
  method: "post",
  path: "/users",
  summary: "ユーザー作成",
  request: {
    body: {
      content: { "application/json": { schema: CreateUserSchema } },
    },
  },
  responses: {
    201: {
      description: "作成成功",
      content: { "application/json": { schema: UserSchema } },
    },
    400: {
      description: "バリデーションエラー",
      content: { "application/json": { schema: ErrorSchema } },
    },
  },
});

// --- OpenAPI仕様を生成 ---
const generator = new OpenApiGeneratorV3(registry.definitions);

const openApiDoc = generator.generateDocument({
  openapi: "3.0.3",
  info: { title: "Sample API", version: "1.0.0" },
});

// JSON出力 → @redocly/cli でドキュメント生成やリンティングに使える
console.log(JSON.stringify(openApiDoc, null, 2));

このアプローチのメリットは以下のとおりです。

  • スキーマ定義がSingle Source of Truthになり、バリデーションロジック・TypeScript型・APIドキュメントがすべて同じZodスキーマから導出される
  • APIの実装とドキュメントが乖離しない
  • フロントエンドとバックエンドで同じスキーマを共有できる

Zodの機能まとめ

ZodのsuperRefineとは?複数項目のカスタムバリデーションを極める

フォーム実装において「パスワードと確認用パスワードの一致」や「開始日と終了日の前後関係」など、複数項目にまたがる相関チェックに悩んだ経験はないでしょうか。こうした複雑な条件を解決するのがsuperRefineです。

基本概念とrefineとの違い

通常のrefineは、単一のフィールドに対して「正しいか、正しくないか(真偽値)」を返すシンプルな独自検証に向いています。一方、superRefineはバリデーションの文脈(コンテキスト)に直接アクセスし、より柔軟なエラー制御を行うためのメソッドです。

メソッド戻り値主な用途柔軟性
refineboolean単一項目のシンプルな独自検証
superRefinevoid複数項目の相関チェック、エラー箇所の指定
入力データ (val) password confirm superRefine ctx. addIssue

Zodスキーマのルール化・標準化の進め方

方針として、「再利用可能なプリミティブのライブラリ化」+「一覧表での管理」の組み合わせが

まずは共通スキーマライブラリを作る(コードベース側)

スプレッドシート管理の前に、まず ~/schemas/common/ のような場所に再利用可能なZodプリミティブを定義します。これが「ルールの実体」になります。

// ~/schemas/common/primitives.ts

/** 半角英数字・記号のみ、最大255文字(メール用) */
export const emailSchema = z
  .string()
  .min(1, 'メールアドレスは必須です')
  .max(255, 'メールアドレスは255文字以内で入力してください')
  .email('メールアドレスの形式が正しくありません')

/** 全角含む、1〜50文字(人名用) */
export const personNameSchema = z
  .string()
  .min(1, '名前は必須です')
  .max(50, '名前は50文字以内で入力してください')

/** 会社名:全角含む、1〜100文字 */
export const companyNameSchema = z.string().min(1).max(100)

/** 社員コード:半角英数字、固定桁数など */
export const companyIdSchema = z.string().regex(/^[A-Z0-9]{6}$/)

/** Graph API の nextLink(URL、最大長はMS側仕様に依存) */
// NOTE: Microsoft Graph の @odata.nextLink は仕様上長くなりうるため上限は緩め
export const graphNextLinkSchema = z.string().url().max(2000).optional()

/** ページング limit:1〜100 */
export const paginationLimitSchema = z.coerce.number().int().min(1).max(100)

そして各route用のスキーマはこれを組み合わせるだけにします。

// ~/schemas/sample.ts
export const getMailRequestSchema = z.object({
  nextLink: graphNextLinkSchema,
})

これにより「ルール=プリミティブの定義」「使用箇所=組み合わせ」に分離されます。

スプレッドシートで管理するもの

コードだけだと全体像が見えないので、プリミティブの一覧表をスプレッドシートで管理するのがおすすめです。

項目内容
スキーマ名emailSchema
分類string / number / date / enum など
パターンemail / 人名 / 会社名 / ID / URL / コード
全角可否可 / 不可
最小長1
最大長255
必須必須 / 任意
正規表現/^.../
制限の根拠業務要件 / DBカラム長 / 外部API仕様
根拠の詳細「MS Graph API の filter 上限」など
エラーメッセージ「メールアドレスは〜」
使用箇所mailSchema.ts, userSchema.ts

分類の切り口

「名前のパターン、メールアドレスのパターンで分ける」という話は、以下の軸で整理すると漏れが減ります。

意味論(何を表すか)での分類が主軸です。
人名・会社名・住所・メール・電話・URL・ID系(UUID、社員コード、会社コード)・金額・日付・コード値(enum)・自由入力テキスト(備考欄など)、といった具合。

制約の出どころを副軸にします。
業務ルール由来、DBスキーマ由来(varchar(255)など)、外部API仕様由来(Graph APIの上限など)、画面UI由来(入力欄の見た目)。出どころが違えば、同じ「メールアドレス」でも上限が変わる可能性があるので分けたほうがいい場合があります。

進め方の手順

最初に既存のスキーマファイルを全部grepして、z.string(), z.number() などの使用箇所を洗い出します。

次にそれをスプレッドシートに転記して、似ているものをグルーピングします(「これとこれは同じ”人名”パターンだな」という作業)。グルーピングできたらプリミティブ名を決めて、primitives.ts に実装します。最後に既存コードを順次置き換えていきます。

一気に全部やると事故るので、新規作成ぶんは新ルール必須、既存は触るときに直すという運用が現実的です。

参考リンク

目次