React Hook Form + Zod: ReactとNext.jsでType-Safeなフォームバリデーションを構築する

Development tutorial - IT technology blog
Development tutorial - IT technology blog

午前2時、スマホが鳴った。プロダクトマネージャーから、本番環境の登録フォームが不正なフォーマットのメールを受け付けているというメッセージが届いた——abc@と入力してもパスしてしまい、サーバーがクラッシュしている。コードを開いてみると、6ヶ月前に手書きされたif/elseのバリデーションの山が目に入った。ロジックはスパゲッティ状態で、誰も触りたがらない代物だった。

あの夜を境に、プロジェクト全体のフォームバリデーションをリファクタリングすることを決意した。それがReact Hook Form + Zodというコンビを本格的に使い始めたきっかけだ。それ以来、古いやり方には戻れなくなった。

従来のフォームバリデーションの問題点

Reactを始めたばかりのほとんどの人が使う、最も一般的な方法:

const [email, setEmail] = useState('');
const [error, setError] = useState('');

const handleSubmit = () => {
  if (!email) {
    setError('メールアドレスを入力してください');
    return;
  }
  if (!email.includes('@')) {
    setError('メールアドレスが無効です');
    return;
  }
  // ... submit
};

フィールドが2〜3個なら問題ない。しかし10個以上のフィールドに3〜4個のルールが付くと、コードはすぐにメンテナンスの悪夢と化す。TypeScriptはこうしたif/elseの連鎖では型エラーを検出できない——バリデーションのバグは実行時に露呈する。たいていは本番環境の午前2時だ。

以前、5万行のコードベースをリファクタリングした経験があるが、最大の教訓はリファクタリング前にしっかりとしたテストカバレッジを確保しておくことだった。しかし、バリデーションロジックがこのようにあちこちに散らばっている場合、テストを書くこと自体が非常に難しい——コンポーネントごとに独自の処理をしており、一貫性が全くない。

ZodとReact Hook Form——完璧な組み合わせ

Zod: スキーマが唯一の信頼できるソース

ZodはデータをバリデートするだけでなくTypeScriptの型をスキーマから直接生成する。一度定義すれば、あとはZodがすべて引き受ける:

import { z } from 'zod';

const registerSchema = z.object({
  email: z.string().email('メールアドレスが無効です'),
  password: z.string().min(8, 'パスワードは8文字以上必要です'),
  age: z.number().min(18, '18歳以上である必要があります'),
});

// TypeScriptはスキーマから型を自動推論——手動宣言は不要
type RegisterForm = z.infer<typeof registerSchema>;
// { email: string; password: string; age: number }

特に気に入っているのはここだ:同じスキーマをクライアントでもサーバーでも使えること。メールのバリデーションルールを変えたい?一箇所修正するだけ——フロントエンドとAPIが自動的に同期され、2箇所の更新を忘れる心配がない。

React Hook Form: パフォーマンスとDXを両立

React Hook Formはrefを使ったuncontrolled inputsで状態を管理する。ユーザーが文字を入力しても?再レンダリングは一切ない。バンドルサイズはわずか〜9KB(gzip)——Formik(〜15KB)より軽く、フィールドが多いフォームでは速度差が明らかだ。Zodとの組み合わせは@hookform/resolversパッケージを通じて:

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

実践:type-safeな登録フォームをゼロから構築する

ステップ1: Zodスキーマの定義

// schemas/register.ts
import { z } from 'zod';

export const registerSchema = z.object({
  name: z.string().min(2, '名前は2文字以上必要です').max(50),
  email: z.string().email('メールアドレスが無効です'),
  password: z
    .string()
    .min(8, 'パスワードは8文字以上必要です')
    .regex(/[A-Z]/, '大文字を1文字以上含める必要があります')
    .regex(/[0-9]/, '数字を1文字以上含める必要があります'),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: 'パスワードが一致しません',
    path: ['confirmPassword'],
  }
);

export type RegisterFormData = z.infer<typeof registerSchema>;

.refine()に注目——クロスフィールドのバリデーション(2つのフィールドを比較する)に使用する。これは従来のバリデーション方法では非常に難しく、エッジケースでバグが生じやすい部分だ。

ステップ2: React Hook Formとの統合

// components/RegisterForm.tsx
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { registerSchema, RegisterFormData } from '@/schemas/register';

export function RegisterForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    setError,
  } = useForm<RegisterFormData>({
    resolver: zodResolver(registerSchema),
  });

  const onSubmit = async (data: RegisterFormData) => {
    try {
      const response = await fetch('/api/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      });

      if (!response.ok) {
        const errorData = await response.json();
        // サーバーサイドのエラーを該当フィールドに設定
        errorData.errors?.forEach((err: { field: string; message: string }) => {
          setError(err.field as keyof RegisterFormData, {
            message: err.message,
          });
        });
      }
    } catch {
      setError('root', { message: '接続エラーが発生しました。後でもう一度お試しください。' });
    }
  };

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

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

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

      {errors.root && <p className="error-global">{errors.root.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '処理中...' : '登録する'}
      </button>
    </form>
  );
}

RHFのhandleSubmitはZodのバリデーションが成功した場合のみonSubmitを呼び出す——スキーマのバリデーションが失敗すると、エラーが自動的に該当フィールドに表示され、手動処理は不要だ。

ステップ3: 同じスキーマでサーバー側も再バリデーション

クライアントサイドのバリデーションはUXのためであり、セキュリティではない。サーバーは常に再バリデーションする必要がある——ここでクライアントとスキーマを共有することが真価を発揮する

// app/api/register/route.ts
import { NextResponse } from 'next/server';
import { registerSchema } from '@/schemas/register';
import { ZodError } from 'zod';

export async function POST(request: Request) {
  try {
    const body = await request.json();

    // クライアントと同じスキーマでパース + バリデーション
    const validatedData = registerSchema.parse(body);

    // validatedDataは完全にTypeScriptの型チェック済み
    await createUser(validatedData);

    return NextResponse.json({ success: true });

  } catch (error) {
    if (error instanceof ZodError) {
      return NextResponse.json(
        {
          errors: error.errors.map(e => ({
            field: e.path.join('.'),
            message: e.message,
          }))
        },
        { status: 400 }
      );
    }

    return NextResponse.json(
      { message: 'Internal server error' },
      { status: 500 }
    );
  }
}

ステップ4: Next.js Server Actionsで使う

Next.js App Routerを使っているなら?Server Actionsを使えばフローがさらにシンプルになる:

// app/register/actions.ts
'use server';

import { registerSchema } from '@/schemas/register';

export async function registerAction(formData: FormData) {
  const rawData = Object.fromEntries(formData);

  // safeParseはthrowしない——{ success, data }または{ success, error }を返す
  const result = registerSchema.safeParse(rawData);

  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
    };
  }

  // result.dataは完全にtype-safe
  await createUser(result.data);
  return { success: true };
}

例外をthrowしたくない場合はsafeParse()を、try/catchでエラーをバブルアップさせたい場合はparse()を使う。

よく使うパターン

  • デフォルト値付きオプショナルフィールド: z.string().optional().default('')
  • Enum: z.enum(['admin', 'user', 'guest']) — TypeScriptがunion型を自動推論
  • Transform: z.string().transform(val => val.trim().toLowerCase()) — スキーマ内でデータをクリーンアップ、手動処理が不要
  • 文字列から数値へ(FormDataでよく使う): z.coerce.number().min(0)
  • 条件付きバリデーション: z.discriminatedUnion('type', [...])(別フィールドの値によってスキーマが変わる場合)

まとめ

あの午前2時の夜以来、3日間でプロジェクト全体のフォームバリデーションをReact Hook Form + Zodに移行した。締め切りに追われたからではなく——一度使い始めると、古いやり方には到底戻れなかったからだ。

スキーマが唯一の信頼できるソースとなる。TypeScriptがコンパイル時にエラーを検出する——本番環境での午前2時ではなく。APIからのエラーがフォームの正しいフィールドに直接マッピングされ、ユーザーはどこが間違っているかすぐにわかる。この3つだけで、古いやり方に戻る気が完全に失せた。

まだ古いバリデーション方法を使っているなら、プロジェクトの中で最も小さなフォーム1つをこの方法で移行してみてほしい。きっとその1つでは止まらないはずだ。

Share: