Chapter 09

React Hook Form + Zod

このコースのメインゴール!フォームライブラリ React Hook Form と バリデーションライブラリ Zod を使って、チャプター8で苦労した登録フォームを劇的に短く・安全に書き直します。Reactの真価はそのエコシステムにあることを体感しましょう。

90 min

このチャプターで学ぶこと

  • なぜフォームライブラリが必要なのかを説明できる
  • Zodでバリデーションスキーマを定義できる
  • useFormフックの基本(register, handleSubmit, formState)を使いこなせる
  • zodResolverでZodとReact Hook Formをつなぎこめる
  • チャプター8のフォームをRHF+Zodで書き直せる
  • ライブラリを活用してコードを劇的に削減できる

React Hook Form + Zod

ついにここまで来ました。 このチャプターがこのコース全体の目的地です。

チャプター8で、フォームを手作りすることの苦労を体験しました。useState の乱立、コピー&ペーストのハンドラー、長いバリデーション関数……。あの苦労は無駄ではありませんでした。今から、その苦労を一気に解消する方法を見せます。

なぜライブラリが必要なのか

チャプター8で作ったコードを振り返ります:

// チャプター8: 9つのuseState、手動バリデーション40行以上、コピペのonChange...
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [nameError, setNameError] = useState('');
const [emailError, setEmailError] = useState('');
const [passwordError, setPasswordError] = useState('');
const [confirmPasswordError, setConfirmPasswordError] = useState('');
const [isSubmitted, setIsSubmitted] = useState(false);
// ... さらにバリデーション関数40行以上 ...

フォームを作るたびにこれを繰り返すのでしょうか?いいえ。Reactの世界には、この問題を解決するために作られたライブラリが存在します。

React Hook Form はフォームの状態管理と送信を担当し、Zod はバリデーションルールの定義を担当します。この2つを組み合わせることで、チャプター8のコードの8割以上を削除できます。

📝 Reactの真の強みはエコシステム

ReactはUIを構築するためのライブラリであり、フォーム、ルーティング、状態管理などは意図的にコアに含まれていません。代わりに、世界中の開発者が作った高品質なライブラリ群(エコシステム)が存在します。

「必要な問題に特化したライブラリを組み合わせる」——これがReact流の問題解決スタイルです。今日はそのパワーを最も体感しやすいフォームで経験します。

インストール

まずは3つのパッケージをインストールします:

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

Zodとは何か

React Hook Formを学ぶ前に、Zodについて理解しましょう。

Zodはスキーマ定義とバリデーションのためのライブラリです。「このデータはこういう形であるべき」というルールをコードで表現できます。

📝 LaravelのForm Requestと同じ概念

LaravelにはForm Requestという仕組みがあります。コントローラーの外にバリデーションルールをまとめて定義できる仕組みです:

// Laravel: FormRequestでルールを定義
public function rules(): array
{
    return [
        'name'  => ['required', 'string', 'max:255'],
        'email' => ['required', 'email'],
        'password' => ['required', 'min:8', 'confirmed'],
    ];
}

Zodはまさにこれと同じ考え方です。バリデーションロジックをコンポーネントの外に「スキーマ」として定義します。

Zodのスキーマ定義

import { z } from 'zod';

// 登録フォームのスキーマを定義する
const registrationSchema = z.object({
  name: z
    .string()
    .min(1, { message: '名前を入力してください' }),

  email: z
    .string()
    .min(1, { message: 'メールアドレスを入力してください' })
    .email({ message: '有効なメールアドレスを入力してください' }),

  password: z
    .string()
    .min(1, { message: 'パスワードを入力してください' })
    .min(8, { message: 'パスワードは8文字以上にしてください' }),

  confirmPassword: z
    .string()
    .min(1, { message: 'パスワード(確認)を入力してください' }),
}).refine(
  // パスワードの一致チェックはrefineで追加する
  (data) => data.password === data.confirmPassword,
  {
    message: 'パスワードが一致しません',
    path: ['confirmPassword'], // エラーをconfirmPasswordフィールドに紐付ける
  }
);

これだけです。 チャプター8で手動で書いていたバリデーションロジック全体が、このスキーマ定義に集約されました。しかも:

TypeScriptとの連携(おまけ)

Zodのもう一つの強みは、スキーマから型を自動生成できることです:

// スキーマから型を自動生成する
type RegistrationFormData = z.infer<typeof registrationSchema>;
// 結果: { name: string; email: string; password: string; confirmPassword: string; }

これにより「バリデーションルール」と「TypeScriptの型」が常に一致します。

React Hook Form の基本

次に、React Hook Formの主役3つを学びましょう。

useForm フック

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

function MyForm() {
  const {
    register,      // inputをRHFに登録する関数
    handleSubmit,  // 送信処理をラップする関数
    formState,     // フォームの状態(エラーなど)を持つオブジェクト
  } = useForm();

  // ...
}

たった1つの useForm() を呼ぶだけです。チャプター8の9つの useState が、これ1行に置き換わります。

register: inputをRHFに登録する

// チャプター8の書き方
<input
  type="text"
  value={name}
  onChange={(e) => setName(e.target.value)}
/>

// React Hook Formの書き方
<input
  type="text"
  {...register('name')}
/>

register('name') はオブジェクトを返し、スプレッド構文で <input> に展開します。valueonChangeref などを自動でセットしてくれます。onChange を自分で書く必要はもうありません。

handleSubmit: 送信処理のラッパー

// チャプター8の書き方
function handleSubmit(e) {
  e.preventDefault();
  const isValid = validate();
  if (isValid) {
    // 送信処理
  }
}

// React Hook Formの書き方
function onSubmit(data) {
  // dataには自動的にバリデーション済みの値が入っている
  console.log(data);
}

// JSX
<form onSubmit={handleSubmit(onSubmit)}>

handleSubmit(onSubmit) がバリデーション実行と e.preventDefault() を自動で行います。バリデーションが通った場合だけ onSubmit が呼ばれ、引数 data にフォームの値が渡されます。

formState.errors: エラー情報へのアクセス

const { formState: { errors } } = useForm();

// 各フィールドのエラーメッセージにアクセス
{errors.name && <p>{errors.name.message}</p>}
{errors.email && <p>{errors.email.message}</p>}

エラーのStateを自分で管理する必要はありません。errors オブジェクトに全部まとまっています。

ZodとReact Hook Formをつなぐ

@hookform/resolverszodResolver を使うと、Zodで定義したスキーマをRHFのバリデーターとして使えます:

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

// スキーマを先に定義する
const registrationSchema = z.object({
  // ... バリデーションルール
});

function RegistrationForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    // resolverにzodResolverを渡すだけでつながる
    resolver: zodResolver(registrationSchema),
  });

  // これだけ!
}

たった1行 resolver: zodResolver(registrationSchema) を追加するだけで、RHFとZodが連携します。

完全リファクタリング: Before & After

いよいよ本番です。チャプター8のフォームを、RHF + Zodで書き直します。コードを並べて比較してみましょう。

Before(チャプター8のコード)

コードの長さと複雑さを確認してください:

// チャプター8: 約90行のコード
import { useState } from 'react';

function RegistrationForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');
  const [nameError, setNameError] = useState('');
  const [emailError, setEmailError] = useState('');
  const [passwordError, setPasswordError] = useState('');
  const [confirmPasswordError, setConfirmPasswordError] = useState('');
  const [isSubmitted, setIsSubmitted] = useState(false);

  function validate() {
    let isValid = true;
    if (!name.trim()) {
      setNameError('名前を入力してください');
      isValid = false;
    } else { setNameError(''); }

    if (!email.trim()) {
      setEmailError('メールアドレスを入力してください');
      isValid = false;
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      setEmailError('有効なメールアドレスを入力してください');
      isValid = false;
    } else { setEmailError(''); }

    if (!password) {
      setPasswordError('パスワードを入力してください');
      isValid = false;
    } else if (password.length < 8) {
      setPasswordError('パスワードは8文字以上にしてください');
      isValid = false;
    } else { setPasswordError(''); }

    if (!confirmPassword) {
      setConfirmPasswordError('パスワード(確認)を入力してください');
      isValid = false;
    } else if (password !== confirmPassword) {
      setConfirmPasswordError('パスワードが一致しません');
      isValid = false;
    } else { setConfirmPasswordError(''); }

    return isValid;
  }

  function handleSubmit(e) {
    e.preventDefault();
    const isValid = validate();
    if (isValid) {
      console.log('送信データ:', { name, email, password });
      setIsSubmitted(true);
    }
  }

  if (isSubmitted) {
    return <p>登録が完了しました!ようこそ、{name}さん!</p>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>名前</label>
        <input type="text" value={name} onChange={(e) => setName(e.target.value)} />
        {nameError && <p style={{ color: 'red' }}>{nameError}</p>}
      </div>
      <div>
        <label>メールアドレス</label>
        <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
        {emailError && <p style={{ color: 'red' }}>{emailError}</p>}
      </div>
      <div>
        <label>パスワード</label>
        <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
        {passwordError && <p style={{ color: 'red' }}>{passwordError}</p>}
      </div>
      <div>
        <label>パスワード(確認)</label>
        <input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} />
        {confirmPasswordError && <p style={{ color: 'red' }}>{confirmPasswordError}</p>}
      </div>
      <button type="submit">登録する</button>
    </form>
  );
}

After(React Hook Form + Zod)

同じフォームです。心の準備はいいですか:

// チャプター9: 約50行のコード(しかもよりパワフル)
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useState } from 'react';

// バリデーションルールをスキーマとして宣言する
const registrationSchema = z.object({
  name: z.string().min(1, { message: '名前を入力してください' }),
  email: z
    .string()
    .min(1, { message: 'メールアドレスを入力してください' })
    .email({ message: '有効なメールアドレスを入力してください' }),
  password: z
    .string()
    .min(1, { message: 'パスワードを入力してください' })
    .min(8, { message: 'パスワードは8文字以上にしてください' }),
  confirmPassword: z.string().min(1, { message: 'パスワード(確認)を入力してください' }),
}).refine((data) => data.password === data.confirmPassword, {
  message: 'パスワードが一致しません',
  path: ['confirmPassword'],
});

function RegistrationForm() {
  const [isSubmitted, setIsSubmitted] = useState(false);
  const [submittedName, setSubmittedName] = useState('');

  // useFormにスキーマを渡すだけで準備完了
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({ resolver: zodResolver(registrationSchema) });

  // バリデーション済みのdataが直接渡ってくる
  function onSubmit(data) {
    console.log('送信データ:', data);
    setSubmittedName(data.name);
    setIsSubmitted(true);
  }

  if (isSubmitted) {
    return <p>登録が完了しました!ようこそ、{submittedName}さん!</p>;
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>名前</label>
        <input type="text" {...register('name')} />
        {errors.name && <p style={{ color: 'red' }}>{errors.name.message}</p>}
      </div>

      <div>
        <label>メールアドレス</label>
        <input type="email" {...register('email')} />
        {errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
      </div>

      <div>
        <label>パスワード</label>
        <input type="password" {...register('password')} />
        {errors.password && <p style={{ color: 'red' }}>{errors.password.message}</p>}
      </div>

      <div>
        <label>パスワード(確認)</label>
        <input type="password" {...register('confirmPassword')} />
        {errors.confirmPassword && <p style={{ color: 'red' }}>{errors.confirmPassword.message}</p>}
      </div>

      <button type="submit">登録する</button>
    </form>
  );
}

export default RegistrationForm;
localhost:5173
React Hook Formで作った登録フォーム
見た目はチャプター8と全く同じ。しかしコードは半分以下になった。
localhost:5173
React Hook Formのエラー表示
ZodスキーマのエラーメッセージがそのままUIに表示される。

変化の対比まとめ

項目チャプター8(手動)チャプター9(RHF + Zod)
useState の数9個2個(送信成功フラグのみ)
onChange ハンドラー4行のコピー&ペースト不要
バリデーションロジック40行超の命令的コード宣言的スキーマ1つ
エラーメッセージの管理4つのState + setError呼び出しerrors オブジェクトに自動
フィールド追加のコストState + onChange + バリデーション条件 + エラーState を全部追加スキーマに1行 + JSXに1フィールド
コード行数(目安)約90行約50行
💡 フィールドを追加するとどう変わるか試してみよう

チャプター8のフォームに新しいフィールド(例: 電話番号)を追加するとしたら、何カ所を変更する必要があるか数えてみてください。次に、チャプター9のコードで同じことをしたら何カ所か比べてみましょう。スキーマに1行、JSXに1フィールドのブロックを追加するだけです。

RHFの追加機能: isSubmittingisValid

React Hook Formには、UIをよりよくするための状態も用意されています:

const {
  register,
  handleSubmit,
  formState: { errors, isSubmitting, isValid },
} = useForm({
  resolver: zodResolver(registrationSchema),
  mode: 'onChange', // 入力のたびにバリデーションを実行する(デフォルトはonSubmit)
});

// isSubmittingは送信中にtrueになる(APIリクエスト中にボタンを無効化するなど)
// isValidはフォーム全体が有効かどうか
<button type="submit" disabled={isSubmitting}>
  {isSubmitting ? '送信中...' : '登録する'}
</button>

mode: 'onChange' を設定すると、入力のたびにリアルタイムでバリデーションが実行されます。ユーザーが入力しながらエラーを修正できる、より良いUXになります。

Zodのバリデーションルール早見表

よく使うZodのバリデーションメソッドをまとめます:

import { z } from 'zod';

z.object({
  // 文字列のバリデーション
  name: z.string()
    .min(1, { message: '必須項目です' })       // 最小文字数
    .max(50, { message: '50文字以内で入力してください' }) // 最大文字数
    .email({ message: 'メール形式で入力してください' })    // メール形式
    .url({ message: 'URL形式で入力してください' }),         // URL形式

  // 数値のバリデーション
  age: z.number()
    .min(18, { message: '18歳以上である必要があります' })
    .max(120, { message: '有効な年齢を入力してください' }),

  // 任意項目(undefined/nullを許容する)
  bio: z.string().optional(),

  // 選択肢から一つを選ぶ
  prefecture: z.enum(
    ['北海道', '青森県', '岩手県', /* ... */],
    { errorMap: () => ({ message: '都道府県を選択してください' }) }
  ),
});
📝 Laravelバリデーションとの対比

LaravelのバリデーションルールとZodを並べると、思想がそっくりです:

// Laravel
'name'  => ['required', 'string', 'max:255'],
'age'   => ['required', 'integer', 'min:18'],
'email' => ['required', 'email'],
// Zod
name: z.string().min(1).max(255),
age: z.number().min(18),
email: z.string().email(),

「バリデーションルールをデータと一緒に定義する」という考え方は同じです。違いは、ZodはJavaScriptの型システムと統合されているため、バリデーションと型定義が同時に得られることです。

フォームのデフォルト値を設定する

初期値を設定したい場合は defaultValues オプションを使います:

const { register, handleSubmit, formState: { errors } } = useForm({
  resolver: zodResolver(registrationSchema),
  defaultValues: {
    name: '',
    email: '',
    password: '',
    confirmPassword: '',
  },
});

これは編集フォームで特に役立ちます。APIから取得したユーザーデータを初期値として渡せます:

// ユーザー編集フォームの例
const { register } = useForm({
  resolver: zodResolver(editUserSchema),
  defaultValues: {
    name: user.name,      // 既存のデータで初期化する
    email: user.email,
  },
});

React Hook Form が採用している仕組み: なぜパフォーマンスが良いのか

React Hook Formがチャプター8の手動実装より優れている理由の一つは、再レンダリングの回数です。

チャプター8では、onChange のたびに setState が呼ばれ、コンポーネント全体が再レンダリングされていました。4つのフィールドに次々と入力すれば、入力のたびに再レンダリングが走ります。

React Hook Formは内部でDOMへの直接参照(ref)を使うため、入力中はStateを更新せず、コンポーネントを再レンダリングしません。送信時や必要なタイミングでのみReactの状態を更新するため、大きなフォームでもサクサク動きます。

📝 これがReact Hook Formのユニークな点

ほとんどのフォームライブラリは「制御コンポーネント」方式(チャプター8と同じアプローチ)を採用しています。React Hook Formが業界で最も人気を集めた理由の一つは、「非制御コンポーネント」を使うことで再レンダリングを最小限に抑えたアーキテクチャです。

✍ やってみよう

演習: 追加フィールドでパワーを確かめる

チャプター9のフォームに、以下の2つのフィールドを追加してみましょう。RHF + Zodのコードの変更がいかに少ないかを体感することが目標です。

追加するフィールド

1. 年齢(age)

  • 数値入力(type="number"
  • 必須
  • 18歳以上、120歳以下
  • エラーメッセージ: 「18歳以上の方のみ登録できます」

2. 都道府県(prefecture)

  • セレクトボックス(<select>
  • 必須(「選択してください」という空のオプションを含む)
  • エラーメッセージ: 「都道府県を選択してください」

Zodでの数値バリデーション注意点

<input type="number"> はHTMLのフォームの値として文字列を返すため、Zodの z.number() を使うには変換が必要です:

// coerce(強制変換)を使うと文字列を数値に自動変換してくれる
age: z.coerce.number()
  .min(18, { message: '18歳以上の方のみ登録できます' })
  .max(120, { message: '有効な年齢を入力してください' }),

セレクトボックスの登録

register はセレクトボックスにも使えます:

<select {...register('prefecture')}>
  <option value="">-- 選択してください --</option>
  <option value="北海道">北海道</option>
  <option value="東京都">東京都</option>
  {/* ... */}
</select>
{errors.prefecture && <p style={{ color: 'red' }}>{errors.prefecture.message}</p>}

チェックリスト

  • スキーマに ageprefecture のルールを追加した
  • JSXに新しいフィールドのブロックを追加した
  • 年齢に18未満の数値を入力するとエラーが表示される
  • 都道府県を選択せずに送信するとエラーが表示される
  • 正しく入力して送信すると data オブジェクトに ageprefecture が含まれている

振り返り

追加に必要だった変更は何カ所でしたか?チャプター8のフォームに同じフィールドを追加するとしたら何カ所の変更が必要か、比べてみてください。

まとめ: ライブラリという選択

このチャプターで学んだことを振り返りましょう。

Zodで学んだこと:

React Hook Formで学んだこと:

そして最も大切なこと:

Reactの真の力は、コア機能そのものよりも、世界中の開発者が作り上げたエコシステムにある。

フォームの問題にはReact Hook Form + Zod。ルーティングの問題にはReact Router。データ取得の問題にはTanStack Query。「問題を認識し、その問題を解決するために作られたライブラリを選ぶ」——このパターンがReact開発のコアスキルです。

次のチャプターでは、このエコシステム全体を俯瞰します。React開発の「地図」を手に入れて、このコースを締めくくりましょう。