[React / Next] Zod を使ったバリデーションの基本

## 概要

`Zod` は `TypeScript` 向けに設計されたスキーマバリデーションライブラリです。
型安全なコードを書くために、データの構造を明確に定義し、バリデーションを簡単に実装できます。

この記事では、問い合わせフォームを例に、`Zod` の基本的な使い方について解説します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842674/4dca2004-16eb-432b-a0a1-5481405e39bb.png)


## 1. Zod のインストール

まずは、npm を利用して Zod をインストールします。

```bash
npm install zod
```

## 2. オブジェクトスキーマの定義

`Zod` では、データの型と検証ルールを スキーマ として定義します。
たとえば、問い合わせフォームの入力データは、以下のようなスキーマで定義できます。

```ts
// src/validations/contracts.ts
import { z } from "zod";

export const ContactSchema = z.object({
  name: z.string().min(1, { message: "名前は入力必須です" }).max(50, { message: "名前は50文字以内で入力してください" }),
  email: z.string().email({ message: "正しいメールアドレスを入力してください" }),
  message: z.string().min(5, { message: "問い合わせ内容は5文字以上必要です" }),
});

export type ContarctSchema = z.infer<typeof ContarctSchema>;
```

このスキーマでは、各フィールドに対して以下の検証ルールを定義しています。
- `name`: 入力必須かつ、文字列で50文字以内
- `email`: 正しいメールアドレス形式の文字列
- `message`: 文字列で、5文字以上

[コンパニオンオブジェクトパターン](https://typescriptbook.jp/tips/companion-object)を用いることで、スキーマと型定義をまとめています。

## 3. Zod の主要メソッド

`Zod` には、主に以下の 2 つのバリデーションメソッドがあります。

### parse()

- **使い方:** データがスキーマに一致しない場合、例外（エラー）を投げます
- **用途:** バリデーションエラーが発生する可能性が低い場面や、`try-catch` ブロックで例外処理を行う場合に利用します

### safeParse()

- **使い方:** 渡したデータを検証し、結果をオブジェクトとして返します
`{ success: boolean, data?: T, error?: ZodError }`
- **用途:** エラーをオブジェクトとして扱い、例外処理せずにエラー内容を細かく取り出して処理する際に有用です。特にフォームの入力チェックなど、ユーザーにフィードバックする場合に便利です

## 4. クライアントバリデーション

クライアントサイドでは、ユーザー入力に対して即時のフィードバックを提供し、フォーム送信前に入力内容が正しいかどうかを検証します。

### 基本の実装の流れ

1. **onChange による個別バリデーション**  
   `onChange` イベントを用いて該当フィールドを `Zod` の `safeParse()` で検証します

2. **エラーメッセージの状態管理**  
   各フィールドのエラーメッセージは `state`で管理します

3. **フォーム送信時の入力値取得**  
   フォーム送信時に、`new FormData(e.currentTarget)` を使って入力値を一括で取得します

4. **フォーム送信時の入力値取得と総合検証**  
   フォーム送信時に `new FormData(e.currentTarget)` で入力値を一括取得し、そのデータ全体を `Zod` で再検証します。検証に失敗した場合はエラー情報を `state` に反映し、正しければ成功処理を実行します

5. **フォーム送信後のレスポンス処理**
    成功時は任意のパスにリダイレクトを行います。
    失敗時はレスポンスデータからエラーメッセージを受け取り、`state` に反映してユーザーに知らせます

### クライアントサイドでのバリデーション例

```tsx
'use client';

import React, { useState, useRef } from "react";
import { ContactSchema } from "@/validations/contracts";
import { useRouter } from 'next/navigation';

const ContactForm: React.FC = () => {
  const router = useRouter();
  const formRef = useRef<HTMLFormElement>(null);
  const [errors, setErrors] = useState<{ [key: string]: string[] }>({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    const { name, value } = e.target;
    const result = ContactSchema.shape[name as keyof typeof ContactSchema.shape].safeParse(value);

    if (!result.success) {
      const errorMessages = result.error.errors.map(err => err.message);
      setErrors(prev => ({ ...prev, [name]: errorMessages }));
    } else {
      setErrors(prev => ({ ...prev, [name]: [] }));
    }
  };

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    setIsSubmitting(true);
    setErrorMessage(null);

    const formData = new FormData(e.currentTarget);
    const data = Object.fromEntries(formData.entries());

    const result = ContactSchema.safeParse(data);
    if (result.success) {
      try {
        const response = await fetch('/api/contacts', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(result.data),
          redirect: 'manual',
        });

        if (response.ok) {
          router.push('/contacts/complete');
        } else {
          try {
            const responseData = await response.json();
            setErrorMessage(responseData.message || 'エラーが発生しました。再度お試しください。');
          } catch {
            setErrorMessage('エラーが発生しました。再度お試しください。');
          }
        }
      } catch {
        setErrorMessage('サーバーとの通信中にエラーが発生しました。');
      }
    } else {
      const newErrors: { [key: string]: string[] } = {};
      result.error.errors.forEach(err => {
        const key = err.path[0] as string;
        if (newErrors[key]) {
          newErrors[key].push(err.message);
        } else {
          newErrors[key] = [err.message];
        }
      });
      setErrors(newErrors);
    }
    setIsSubmitting(false);
  };

  return (
    <form ref={formRef} onSubmit={handleSubmit} className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
      {errorMessage && (
        <div className="mb-4 p-3 rounded-md bg-red-100 text-red-700">
          {errorMessage}
        </div>
      )}

      <div className="mb-4">
        <label className="block text-gray-700 text-sm font-bold mb-2">名前</label>
        <input
          name="name"
          onChange={handleChange}
          className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ${
            errors.name?.length > 0 ? "border-red-500 focus:ring-red-500" : "border-gray-300 focus:ring-blue-500"
          }`}
        />
        {errors.name?.length > 0 && (
          <div className="mt-1 text-sm text-red-500">
            {errors.name.map((error, index) => (
              <p key={`name-error-${index}`}>{error}</p>
            ))}
          </div>
        )}
      </div>

      <div className="mb-4">
        <label className="block text-gray-700 text-sm font-bold mb-2">メールアドレス</label>
        <input
          name="email"
          type="email"
          onChange={handleChange}
          className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ${
            errors.email?.length > 0 ? "border-red-500 focus:ring-red-500" : "border-gray-300 focus:ring-blue-500"
          }`}
        />
        {errors.email?.length > 0 && (
          <div className="mt-1 text-sm text-red-500">
            {errors.email.map((error, index) => (
              <p key={`email-error-${index}`}>{error}</p>
            ))}
          </div>
        )}
      </div>

      <div className="mb-6">
        <label className="block text-gray-700 text-sm font-bold mb-2">問い合わせ内容</label>
        <textarea
          name="message"
          onChange={handleChange}
          rows={5}
          className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ${
            errors.message?.length > 0 ? "border-red-500 focus:ring-red-500" : "border-gray-300 focus:ring-blue-500"
          }`}
        />
        {errors.message?.length > 0 && (
          <div className="mt-1 text-sm text-red-500">
            {errors.message.map((error, index) => (
              <p key={`message-error-${index}`}>{error}</p>
            ))}
          </div>
        )}
      </div>

      <button
        type="submit"
        disabled={isSubmitting}
        className={`w-full font-bold py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors ${
          isSubmitting
            ? 'bg-blue-300 cursor-not-allowed'
            : 'bg-blue-500 hover:bg-blue-600 text-white'
        }`}
      >
        {isSubmitting ? '送信中...' : '送信'}
      </button>
    </form>
  );
};

export default ContactForm;
```

## 4. サーバサイドバリデーション

クライアント側のバリデーションだけでは、悪意のあるリクエストを完全に防ぐことはできません。
サーバサイドでもバリデーションを実施し、不正なデータを防ぐ必要があります。

以下は`Nextjs` の `Route Handlers` を用いたサーバーサイドでのバリデーション実装例です。
ご利用のAPIに合わせて実装例を変更し、動作をご確認ください。

```ts
// api/contact.ts
import { NextResponse } from "next/server";
import { ContactSchema } from "@/validations/contracts";

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

    const result = ContactSchema.safeParse(body);

    if (!result.success) {
      return NextResponse.json({
        message: "バリデーションエラー",
        errors: result.error.errors,
      }, { status: 400 });
    }

    return NextResponse.json({
      message: "正常にデータを受信しました",
      data: result.data
    }, { status: 200 });

  } catch (error: unknown) {
    console.error("問い合わせ処理中にエラーが発生しました:", error);
    return NextResponse.json({
      message: "リクエストの処理中にエラーが発生しました"
    }, { status: 500 });
  }
}
```

## 5. 動作検証

以上で実装は完了です！
バリデーションが正しく動いているか確認してみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842674/af37a5d1-0fa7-4868-a95d-f6954e2d1db6.png)


## 6. [補足] ReactHookForm + Zod

`react-hook-form` を用いると、よりシンプルにフォームを実装できます。
`Zod` との相性も良く、以下のようなメリットがあります。

- **バリデーション結果をステートで明示的に管理する必要がない**
`react-hook-form` が各フィールドのエラー状態や、送信ステータスを自動管理してくれます。

- **フィールド登録（ register ）が超シンプル**
`register()` を呼び出すだけで、`onChange` / `onBlur` / `ref` の登録などが一括で行われます。

- **最適化・パフォーマンス対策**
フィールドレベルの変更に応じて必要最小限の再レンダリングしか発生しないよう工夫されています。

- **Zod のスキーマをそのまま使える**
`@hookform/resolvers/zod` を使うことで、Zod で定義したスキーマによるバリデーションを自動的に行えます。

過去に記事を投稿しているので、よろしければご参考ください。

https://qiita.com/PenPe/items/a829efc4de14af7af3e1#react-hook-form%E3%81%A8%E3%81%AF


### サンプル実装例
```tsx
'use client';

import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { ContactSchema } from "@/validations/contracts";
import { useRouter } from 'next/navigation';
import type { z } from 'zod';

type FormData = z.infer<typeof ContactSchema>;

const ContactForm: React.FC = () => {
  const router = useRouter();
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset
  } = useForm<FormData>({
    resolver: zodResolver(ContactSchema),
    mode: "onBlur"
  });

  const onSubmit = async (data: FormData) => {
    setErrorMessage(null);

    try {
      const response = await fetch('/api/contacts', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(data),
        redirect: 'manual',
      });

      if (response.ok) {
        reset();
        router.push('/contacts/complete');
      } else {
        try {
          const responseData = await response.json();
          setErrorMessage(responseData.message || 'エラーが発生しました。再度お試しください。');
        } catch {
          setErrorMessage('エラーが発生しました。再度お試しください。');
        }
      }
    } catch {
      setErrorMessage('サーバーとの通信中にエラーが発生しました。');
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
      {errorMessage && (
        <div className="mb-4 p-3 rounded-md bg-red-100 text-red-700">
          {errorMessage}
        </div>
      )}

      <div className="mb-4">
        <label htmlFor="name" className="block text-gray-700 text-sm font-bold mb-2">名前</label>
        <input
          id="name"
          {...register("name")}
          className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ${
            errors.name ? "border-red-500 focus:ring-red-500" : "border-gray-300 focus:ring-blue-500"
          }`}
        />
        {errors.name && (
          <div className="mt-1 text-sm text-red-500">
            <p>{errors.name.message}</p>
          </div>
        )}
      </div>

      <div className="mb-4">
        <label htmlFor="email" className="block text-gray-700 text-sm font-bold mb-2">メールアドレス</label>
        <input
          id="email"
          type="email"
          {...register("email")}
          className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ${
            errors.email ? "border-red-500 focus:ring-red-500" : "border-gray-300 focus:ring-blue-500"
          }`}
        />
        {errors.email && (
          <div className="mt-1 text-sm text-red-500">
            <p>{errors.email.message}</p>
          </div>
        )}
      </div>

      <div className="mb-6">
        <label htmlFor="message" className="block text-gray-700 text-sm font-bold mb-2">問い合わせ内容</label>
        <textarea
          id="message"
          {...register("message")}
          rows={5}
          className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ${
            errors.message ? "border-red-500 focus:ring-red-500" : "border-gray-300 focus:ring-blue-500"
          }`}
        />
        {errors.message && (
          <div className="mt-1 text-sm text-red-500">
            <p>{errors.message.message}</p>
          </div>
        )}
      </div>

      <button
        type="submit"
        disabled={isSubmitting}
        className={`w-full font-bold py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors ${
          isSubmitting
            ? 'bg-blue-300 cursor-not-allowed'
            : 'bg-blue-500 hover:bg-blue-600 text-white'
        }`}
      >
        {isSubmitting ? '送信中...' : '送信'}
      </button>
    </form>
  );
};

export default ContactForm;
```

Zodのみで実装した時と比較してスッキリしましたね！

## まとめ

`react-hook-form`, `yup` は過去に学びましたが、 `Zod` を初めて触れました。
近年のトレンド...？

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842674/046376d3-5d41-431c-83cb-dab806b63dbb.png)

https://npmtrends.com/formik-vs-jotai-vs-react-hook-form-vs-yup-vs-zod