🧱

React Hook Form × Zodで実務に耐えるフォーム実装を作る

に公開

はじめに

React Hook Form(以下RHF)を使い始めて、フォーム実装が一気に楽になった──
これはRHFを使い始めた人なら誰しもが感じることだと思います。

useFormregister でサクッと入力値を扱えるのは便利。
でも、こんな課題を感じたことはありませんか?

  • バリデーションルールを毎回フォーム内で直書きしていて読みにくい
  • 型定義とバリデーションが別々になっていて管理が面倒
  • ちょっと複雑な条件(例:パスワード一致チェック)を書くと急に混乱する

そんなときに便利なのが Zod
https://zod.dev/

RHFとZodを組み合わせることで、
型とバリデーションを1つのスキーマにまとめて管理できるようになります。

今回は、私が実務でも使用して「これならスッキリ書ける!」と感じた
React Hook Form + Zod構成の基本を紹介します。


1. まずは必要なパッケージをインストール

npm install react-hook-form zod @hookform/resolvers

RHFとZodをつなぐのが @hookform/resolvers です。
zodResolver という関数が含まれており、Zodで定義したスキーマをRHFで使えるようにしてくれます。


2. Zodでスキーマを定義する

まずは、入力ルールをZodで定義します。

import { z } from "zod";

export const formSchema = z.object({
  email: z.email("正しいメールアドレスを入力してください"),
  age: z.coerce.number().min(18, "18歳以上である必要があります"),
  password: z.string().min(8, "8文字以上で入力してください"),
});

export type FormValues = z.infer<typeof formSchema>;

ここでのポイントは2つ。

  • z.coerce.number():文字列でも自動的に数値に変換してくれる
  • z.infer:スキーマから自動で型を生成できる

このほかにも様々な型を扱えます。詳しくは以下を参照。
https://zod.dev/api#emails

このスキーマを使えば、型とバリデーションルールが常に一致します。
「ルールを変えたのに型を直し忘れた」みたいな事故を防げます。


3. RHFにzodResolverを組み合わせる

まずは、RHF単体の場合のコードから見てみましょう。

RHF単体でのフォーム実装

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

type FormValues = {
  email: string;
  age: number;
  password: string;
};

export const UserForm = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormValues>({
    defaultValues: {
      email: "",
      age: 20,
      password: "",
    },
  });

  const onSubmit = (data: FormValues) => {
    console.log(data);
  };

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

      <input
        type="number"
        {...register("age", {
          valueAsNumber: true,
          min: { value: 18, message: "18歳以上である必要があります" },
        })}
        placeholder="年齢"
      />
      {errors.age && <p>{errors.age.message}</p>}

      <input
        type="password"
        {...register("password", {
          required: "パスワードは必須です",
          minLength: { value: 8, message: "8文字以上で入力してください" },
        })}
        placeholder="パスワード"
      />
      {errors.password && <p>{errors.password.message}</p>}

      <button type="submit">送信</button>
    </form>
  );
};

一見問題なさそうに見えますが、

  • バリデーションルールがフォームの中に直書きされていて読みにくい
  • FormValues の型と実際のルールが別管理になっている

という欠点があります。
フォーム項目が増えてくると、コードがどんどん煩雑になっていきます。


Zodを組み合わせた場合

次に、同じフォームを Zod + zodResolver で書き換えた例を見てみましょう。

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

const formSchema = z.object({
  email: z.string().email("正しいメールアドレスを入力してください"),
  age: z.coerce.number().min(18, "18歳以上である必要があります"),
  password: z.string().min(8, "8文字以上で入力してください"),
});

type FormValues = z.infer<typeof formSchema>;

export const UserForm = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      email: "",
      age: 20,
      password: "",
    },
  });

  const onSubmit = (data: FormValues) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} placeholder="メールアドレス" />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="number" {...register("age")} placeholder="年齢" />
      {errors.age && <p>{errors.age.message}</p>}

      <input type="password" {...register("password")} placeholder="パスワード" />
      {errors.password && <p>{errors.password.message}</p>}

      <button type="submit">送信</button>
    </form>
  );
};

RHF単体のときと比べて、フォーム内部からルールが完全に消えています。
Zod側でスキーマを定義しておけば、

  • 型定義 (FormValues)
  • バリデーションルール
    を1つの場所で管理できるようになります。

また、ここでは defaultValues を設定しています。
実務では「編集フォームで既存データを表示する」「APIから値を初期化する」といったケースが頻出するため、
defaultValues を意識的に書いておくのは重要な習慣です。


比較まとめ

観点 RHF単体 RHF + Zod
バリデーション定義 フォーム内に直書き Zodスキーマで一元管理
型定義 手動で別途用意 スキーマから自動生成 (z.infer)
可読性 項目が増えると煩雑 スッキリ、再利用もしやすい
実務での運用 小規模向け 中〜大規模開発に適する

4. 実務で感じたメリット

実際のプロジェクトにZodを導入したとき、特に便利だったのは以下の3点でした。

  1. 共通のインプットパーツで同じバリデーションスキーマを再利用できる
    formSchema の設定を一つの場所に書き、それをパーツや画面ごとに使いまわせる

  2. チーム開発でルールの共通化ができる
    → 「メールは必須?」「パスワードは何文字から?」のような仕様を明文化できる

  3. 型推論が正確になる
    data.email などを扱うときにIDEが自動で型補完してくれる

これらは、RHF単体では意外と見落としがちなポイントです。
Zodを使うと「バリデーションルール=型定義」という一貫した設計ができるのが魅力です。


5. 応用:条件付きバリデーション

Zodでは、2つ以上のフィールドの関係をチェックするような
条件付きバリデーションも簡単に書けます。

const schema = z
  .object({
    password: z.string().min(8),
    confirm: z.string(),
  })
  .refine((data) => data.password === data.confirm, {
    message: "パスワードが一致しません",
    path: ["confirm"],
  });

refine は“最終チェック”のような役割で、
フォーム全体を見てルールを追加したいときに便利です。

たとえば「チェックボックスがオンのときだけ特定の入力が必須」なども書けます。


6. スキーマを分離して保守しやすく

Zodのスキーマは、フォームごとにファイルを分けておくのが実務ではおすすめです。
※ディレクトリ構成はあくまで一例 (画面ごとにschema.tsファイルを作成して管理したほうが良い場合もあります)

src/
 ├─ schemas/
 │   ├─ userFormSchema.ts
 │   └─ loginFormSchema.ts
 └─ components/
     └─ forms/
         └─ UserForm.tsx

同じスキーマをAPIバリデーションでも使い回すことで、
「フロントとバックのルール不一致」を防げます。
Next.jsなどのフルスタック環境では、特にこの設計が効果的です。


まとめ

React Hook Form × Zod の組み合わせは、
フォーム実装を型安全かつ保守しやすくしてくれます。

  • RHFのuseFormでフォームの制御を行う
  • Zodで型とバリデーションをまとめる
  • zodResolverで両者をつなぐ

最初は少しコードが増えるように見えますが、
長期的には「どこに何を書けばいいか」が明確になり、
後から読む人にも優しい実装になります。

Discussion