💎

ZodとHTMLだけでバリデーションをやってみた。react-hook-form未使用

2024/08/14に公開

こんにちは。最近暑さにめっぽうやられてるツーさんです 😁。こんな時はゾッとする話でも聞いて涼みたいですね。はい。とゆうわけで今回は Zod と HTML だでバリデーションをやってみたのでその内容を記事にしてみました。
※ 本記事では react-hook-form は使用していません。

記事内に掲載しているソースコードは Github でも確認できます。

https://github.com/twosun-8-git/zod

Zod とは 🤔

Zod のサイト見ると "TypeScript-first schema validation with static type inference" と書かれています。日本語にすると 「TypeScript の静的型システムを最大限に活用し、型推論機能と連携したスキーマベースのデータ検証ライブラリ」 と言ったところでしょうか。

主に下記の特徴があります。

  • TypeScript と完全に統合されているので静的型チェック行える。
  • 定義したスキーマから型を生成できる
  • String, Number, Date, Enum などの多様な型に対応したバリデーションが行える
  • 最小や最大の文字数や数値などのデータ量のバリデーションが行える
  • 詳細なエラーハンドリングが設定できる

多数の機能が用意されているのでここで全てを紹介することはしません(多すぎてムリ)。詳しくは下記の公式サイトご参考ください。

公式サイトはこちら → https://zod.dev/?id=basic-usage

開発環境を作る 🛠️

では、開発環境を準備していきたいと思います。今回は Next を利用します。Node や Next などの詳しいバージョンは下記のようになっています。

Node や Next のバージョン

  • Node.js(18.20.4)
  • React(^18)
  • Next(14.2.5)
  • Zod(3.23.8)

では、まず create-next-app で Next アプリの雛形を作ります。今回はアプリ名を my-zod としました。

npx create-next-app

/ /下記のように設定
What is your project named?  my-zod
Would you like to use TypeScript?  Yes
Would you like to use ESLint?  Yes
Would you like to use Tailwind CSS?  No
Would you like to use `src/` directory?  Yes
Would you like to use App Router? (recommended)  Yes
Would you like to customize the default import alias (@/*)?  No

Zod をインストールします。

npm install zod

これで準備ができました。
今回は下記のような 「ユーザーのプロフィールページ」 を作成していきます。
zodの検証用ユーザーのプロフィールページ


フォームの入力項目とバリデーションルールなどは下記にまとめてあります。

項目 要素 バリデーションルール
フルネーム Text String 必須( 2 文字以上 )
年齢 Text Number 必須( 1 以上の整数のみ、全角数字不可 )
性別 Select String 必須( "female", "male", "other" のいずれか )
誕生日 Date String 必須
メールアドレス Text String 必須( メールアドレス形式 )
メールアドレス(確認用) Text String 必須( メールアドレス形式、メールアドレスが一致しているか )
URL Text String 任意( 値がある場合は URL 形式かをチェックする )
配偶者の有無 Radio Number 必須( あり = 1, なし = 0 )
自己紹介 Textarea String 任意( 値がある場合は 10 文字以上 30 文字以内 )
利用規約への同意 Checkbox Boolean 必須( true のみ )

なお、今回は CSS については解説はしません。CSS が必要な方は下記からコピペして利用してください。

CSS

Zod の基本的な使い方 🔍

まずは Zod の基本的な使い方です。
簡単なスキーマを例に検証方法をみてみましょう。

"use client";
import { z } from "zod";

// スキーマ定義
const FullName = z.string().min(2);

/** OK */
console.log(FullName.parse("山田 太郎")); // OK
console.log(FullName.parse("山田")); // OK

/** NG */
console.log(FullName.parse("")); // 空
console.log(FullName.parse("山")); // 2文字未満のため
console.log(FullName.parse(1)); // 型が違う

では、解説です。
z.string() で型を定義し .min(2) で値の最小長を設定したスキーマを作成します。

const FullName = z.string().min(2);

parse で検証

次に parse で検証を行い結果をコンソールに出力します。

/** OK */
console.log(FullName.parse("山田 太郎"));
console.log(FullName.parse("山田"));

/** NG */
console.log(FullName.parse("")); // 空
console.log(FullName.parse("山")); // 2文字未満のため
console.log(FullName.parse(1)); // 型が違う

なお、parse で検証を行うと NG だった場合にエラーを throw するので環境によってはアプリがクラッシュする場合があります。そのような時は safeParse を使いましょう。
safeParse はアプリをクラッシュさせず、検証結果をオブジェクトで返すことができます。

safeParse で検証

/**
 * safeParseに変更した例
 * */

/** OKの場合 data を返す */
console.log(FullName.safeParse("山田 太郎")); // { success: true; data: "山田 太郎" }
console.log(FullName.safeParse("山田")); // { success: true; data: "山田" }

/** NGの場合 ZodError を返す */
console.log(FullName.safeParse("")); // { success: false; error: ZodError  }
console.log(FullName.safeParse("山")); // { success: false; error: ZodError }
console.log(FullName.safeParse(1)); // { success: false; error: ZodError }

エラー内容を確認

NG の時に error: ZodError だけだとエラーの詳細がわかりません。エラーの内容を出力してみましょう。

/** エラー内容を出力 */
const result = FullName.safeParse(1);
console.log(result.error?.issues[0]);

/** エラー内容 */
// {
//   "code": "invalid_type", // エラーコード(型エラー)
//   "expected": "string", // 定義されている型
//   "received": "number", // 受け取った型
//   "path": [],
//   "message": "Expected string, received number" // エラーメッセージ
// }

これなら 「エラーコードやエラーメッセージ」 などがわかるので対処しやすいですね。

今回作るサンプルでは、フォームのバリデーションは 「try ~ catch」 を利用するので parse でバリデーションチェックを行っていますが、上記のように "検証結果をコンソールで確認する" ぐらいでしたら safeParse を使用した方が確認しやすいかと思います。

ここまで Zod の基本的な使い方でした。

フォーム作成とスキーマ定義 📋

まずはフォームの見た目だけ先に作ります。
<form>noValidate を追加してブラウザのデフォルトのバリデーションは無効にしておきます。

"use client";

export default function Home() {
  return (
    <main>
      // noValidate 追加
      <form className="form" noValidate>
        <div className="form-group">
          <label htmlFor="fullName">名前</label>
          <div>
            <input id="fullName" type="text" />
          </div>
        </div>
        <div className="form-group">
          <label htmlFor="age">年齢</label>
          <div>
            <input id="age" type="text" />
          </div>
        </div>
        <div className="form-group">
          <label htmlFor="gender">性別</label>
          <div>
            <select id="gender">
              <option value="">選択してください</option>
              <option value="male">男性</option>
              <option value="female">女性</option>
              <option value="other">無回答</option>
            </select>
          </div>
        </div>
        <div className="form-group">
          <label htmlFor="birthday">生年月日</label>
          <div>
            <input id="birthday" type="date" />
          </div>
        </div>
        <div className="form-group">
          <label htmlFor="email">メールアドレス</label>
          <div>
            <input id="email" type="email" />
          </div>
        </div>
        <div className="form-group">
          <label htmlFor="confirmEmail">メールアドレス(確認用)</label>
          <div>
            <input id="confirmEmail" type="email" />
          </div>
        </div>
        <div className="form-group">
          <label htmlFor="url">URL</label>
          <div>
            <input id="url" type="text" />
          </div>
        </div>
        <div className="form-group">
          <label>配偶者</label>
          <div className="radio-group">
            <label>
              <input name="spouse" type="radio" value="1" />
              あり
            </label>
            <label>
              <input name="spouse" type="radio" value="0" />
              なし
            </label>
          </div>
        </div>
        <div className="form-group">
          <label htmlFor="comment">自己紹介</label>
          <div>
            <textarea id="comment"></textarea>
          </div>
        </div>
        <div className="form-group">
          <div>
            <label>
              <input id="agree" type="checkbox" />
              <span>利用規約に同意する</span>
            </label>
          </div>
        </div>
        <div className="button-group">
          <button type="submit">送信</button>
        </div>
      </form>
    </main>
  );
}

スキーマを定義する

フォームを作ったら次にバリデーションで使用するためのスキーマを作成します。
なお、schema.ts などで別ファイルに定義しそれを読み込んでも構いません。

"use client";
import { z } from "zod"; // z をインポート

// スキーマを定義する
+ const FormSchema = z
+  .object({
+    fullName: z
+      .string({
+        required_error: "名前を入力してください",
+        invalid_type_error: "入力が正しくないようです",
+      })
+      .min(2, { message: "2文字以上入力してください" }),
+    age: z
+      .number({
+        required_error: "年齢を入力してください",
+        invalid_type_error: "半角数字で入力してください",
+      })
+      .min(1, { message: "1以上を入力してください" })
+      .int({ message: "年齢は正数で入力してください" }),
+    gender: z.enum(["", "female", "male", "other"], {
+      required_error: "性別を選択してください",
+    }),
+    birthday: z
+      .string({ required_error: "生年月日を入力してください" })
+      .pipe(z.coerce.date()),
+    email: z
+      .string({
+        required_error: "メールアドレスを入力してください",
+      })
+      .email({ message: "有効なメールアドレスを入力してください" }),
+    confirmEmail: z
+      .string({
+        required_error: "確認用メールアドレスを入力してください",
+      })
+      .email({ message: "有効なメールアドレスを入力してください" }),
+    url: z.string().url({ message: "有効なURLを入力してください" }).optional(),
+    spouse: z.number({
+      required_error: "選択してください",
+      invalid_type_error: "入力形式が正しくありません",
+    }),
+    comment: z
+      .string()
+      .min(10, { message: "コメントは10文字以上で入力してください" })
+      .max(30, { message: "コメントは30文字以内で入力してください" })
+      .optional(),
+    agree: z.coerce.boolean().refine((val) => val === true, {
+      message: "利用規約に同意する必要があります",
+    }),
+  })
+  // メールアドレスが一致しているかをチェックする
+  .refine((data) => data.email === data.confirmEmail, {
+    message: "メールアドレスが一致しません",
+    path: ["confirmEmail"],
+  });

export default function Home() {
  return (
    <main>
      // noValidate 追加
      <form className="form" noValidate>
        〜 省略 〜
      </form>
    </main>
  );
}

スキーマ内容は下記の通り( required_error などは後述します )。

名称 スキーマ バリデーションルール
fullname z.string().min(2) String 必須( 2 文字以上 )
age z.number().min(1).int() Number 必須( 1 以上 & 整数のみ )
gender z.enum(["female", "male", "other"]) Enum 必須( "female", "male", "other" のどれか )
birthday z.string().pipe(z.coerce.date()) String 必須( z.coerce.date()で日付形式に変換できること )
email z.string().email() String 必須( メールアドレス形式 )
confirmEmail z.string().email() String 必須( メールアドレス形式 )
url z.string().url().optional() String 任意( URL 形式 )
spouse z.number() Number 必須
comment z.string().min(10).max(30).optional() String 任意(10 〜 30 文字)
agree z.coerce.boolean().refine((val) => val === true, {}) boolean 必須(boolean に変換できる & true のみ)

エラーメッセージの設定について

スキーマ定義の中に required_error, invalid_type_error, message とゆうのが随所にありますね。ここではそれぞれのバリデーションエラー時に表示するメッセージを設定しています。

"メールアドレス""年齢" など項目によって適切なエラーメッセージを表示したいので今回はできるだけ細かく設定しています。

設定をしなかった場合はデフォルトのメッセージが適用されます。
fullName: z.string().min(2) を例にすると下記のように表示されます。

名称 メッセージの例 バリデーションルール
required_error Required 必須項目を入力していない時
invalid_type_error Expected string, received number 型が異なる時
message String must contain at least 2 character(s) .min などのルールを満たしていない時

.min().int(), .email() の時など Zod はエラーの状況に合わせて適切なメッセージを表示することができます。

refine について

スキーマ内の agree と最後で refine を使用しています。

// agree
agree: z.coerce.boolean().refine((val) => val === true, {
  message: "利用規約に同意する必要があります",
}),

// メールアドレスが一致しているかをチェックする
.refine((data) => data.email === data.confirmEmail, {
  message: "メールアドレスが一致しません",
  path: ["confirmEmail"], // エラーをconfirmEmailフィールドに関連付けそこにエラーメッセージを表示する
});

refine を使うとオリジナルのバリデーションルールを組み込むことができます。
構文は下記のように 「第一引数にチェック用の関数、第二引数にはオプション」 を設定します。

z.string().refine("チェック用の関数", "オプション");

第二引数のオプションでは "エラーメッセージを特定のフィールドに関連付ける(どこに表示するか)" ことができます。今回の場合だとこのようになります。

// agree
/**
 * 1. z.coerce.boolean()で値をbooleanに変換
 * 2. .refine((val) => val === true で値が true であることをチェック
 * 3. true じゃない場合に、message: "利用規約に同意する必要があります" を表示
*/
agree: z.coerce.boolean().refine((val) => val === true, {
  message: "利用規約に同意する必要があります",
}),

// メールアドレスが一致しているかをチェックする
/**
 * 1. data には fullName や email などが格納されている
 * 2. email と confirmEmail が同一かチェック
 * 3. 同一出ない場合 message: "メールアドレスが一致しません" を表示
 * 4. message は path: ["confirmEmail"] に関連づけているのでそこにエラーメッセージが表示される
*/
.refine((data) => data.email === data.confirmEmail, {
  message: "メールアドレスが一致しません",
  path: ["confirmEmail"], // エラーをconfirmEmailフィールドに関連付けそこにエラーメッセージを表示する
});

"agree" の場合は val や path が自動的に設定されますが、メールアドレス確認の箇所で使用している refinez.object().recine() となっているので全オブジェクト(data)の中から data.email などで指定する必要があります。

refine についてより詳しく知りたい方は下記の公式サイトをご参考ください。
https://zod.dev/?id=refine

バリデーションを適用する ✅

スキーマの定義が完了したらそれをもとにフォームにバリデーションを適用していきます。

スキーマから型を生成する

定義したスキーマ FormSchema から 型を生成します( FormData )。
同時にエラー用の型( FormErrors )も作っておきます。

  〜 省略 〜
    message: "メールアドレスが一致しません",
    path: ["confirmEmail"],
  });

+ type FormData = z.infer<typeof FormSchema>;
+ type FormErrors = { [K in keyof FormData]?: string[] };

export default function Home() {
  〜 省略 〜

State の追加と初期値の設定

次にフォームの状態を管理するための State を作成し、同時に初期値を設定します。
初期値は入力項目分すべてを設定する必要はありません。今回は "spouse(ラジオボタン)、"agree(チェックボックス)" のみ初期値を設定しておきます。

"use client";
+ import { useState } from "react"; // useStateをインポート
  〜 省略 〜

export default function Home() {

+  const [formData, setFormData] = useState<FormData | {}>({
+    spouse: 1, // 1 = 配偶者あり、0 = 配偶者なし。どちらでも良い。
+    agree: true, // 規約同意は必須のため最初からチェックしておく。
+  });

  〜 省略 〜

onChange と onSubmit 関数を追加

次にフォームの変更時と送信時の処理( handleChange, handleSubmit )を設定します。

export default function Home() {

  const [formData, setFormData] = useState<FormData | {}>({
    spouse: 1, // 1 = 配偶者あり、0 = 配偶者なし。どちらでも良い。
    agree: true, // 規約同意は必須のため最初からチェックしておく。
  });

+  /** onChange */
+  const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
+    const { id, name, value, type } = e.target;
+    const fieldName = id || name; // id or name をオブジェクトのキーとして使用
+
+    // type="checkbox"の場合はチェックされているかを判定する
+    if (type === "checkbox") {
+      const _checkbox = e.target as HTMLInputElement;
+      const _isChecked = _checkbox.checked; // boolean
+      setFormData((prev) => ({ ...prev, [fieldName]: _isChecked }));
+    }
+    // valueが "" の場合はブラウザの初期値(undefined)に戻す
+    else if (value === "") {
+      setFormData((prev) => ({ ...prev, [fieldName]: undefined }));
+    }
+
+    // valueが半角数字の場合はnumber型に変換( ブラウザから "string" で送られるため )
+    else if (/^[0-9]+$/.test(value)) {
+      setFormData((prev) => ({ ...prev, [fieldName]: Number(value) }));
+    }
+
+    // その他の処理
+    else {
+      setFormData((prev) => ({ ...prev, [fieldName]: value }));
+    }
+  };


+  /** onSubmit */
+  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    setErrors({}); // エラーをクリア
+
+    try {
+      FormSchema.parse(formData); // .parseでバリデーションチェック
+      console.log(formData); // 成功時のデータをコンソールに表示
+    } catch (error) {
+      if (error instanceof ZodError) {
+        console.log(error.formErrors.fieldErrors);  // エラーをコンソールに表示
+        setErrors(error.formErrors.fieldErrors);  // エラー用ステート更新
+      }
+    }
+  };

動作確認

ここまでできたら実際にフォームを操作して動作チェックしてみましょう。
npm run dev で開発用サーバーを起動しフォームに様々な値を入力してみましょう。

npm run dev

何も入力せず送信した場合
何も入力せず送信した場合

メールアドレスが一致していない
メールアドレスが一致していない

必須項目や型のバリデーションがちゃんと動いていますし、送信時の処理も問題ありません。

メールアドレスの一致性の確認の挙動について 📩

メールアドレスの一致性の確認は一見ちゃんと動いているように見えます。
ですが、一度下記の操作を行ってみてください。

  1. ブラウザをリロードしてフォームをまっさらにする(名前や年齢は入力しない)
  2. メールアドレスに 「hoge@ex.com」 を入力
  3. メールアドレス(確認用)に 「moge@ex.com」 を入力
  4. 送信ボタンをクリック

上記の手順であれば"名前"や"年齢"の必須項目のエラーメッセージが表示されるのは当然として、"メールアドレスが異なるためエラーメッセージ「メールアドレスが一致しません」も表示される" と予想できると思います。

ですが、実際には下記のようにエラーメッセージ 「メールアドレスが一致しません」 は表示されていません。

メールアドレスが異なるなのに「メールアドレスが一致しません」が表示されていない
メールアドレスが異なるなのに「メールアドレスが一致しません」が表示されていない

なぜでしょうか?
これは Zod が実行するバリデーションの順序に関係しています。


先ほどの FormSchema を極端に簡略化したのを例に説明します。

const FormSchema = z
  .object("fullNameやageなど")
  .refine("メールアドレスの一致を確認");

Zod はまず z.object("fullNameやageなど") のバリデーションを行います。そして、バリデーションエラーがあれば処理を中断するようになっています。

そのため .refine("メールアドレスの一致を確認") の処理が実行されず 「メールアドレスが一致しません」 のエラーメッセージが表示されない状態になります。下記に簡単に箇条書きしてみました。

今度は試しに下記の順番で操作を行ってみましょう。「メールアドレスが一致しません」 とエラーメッセージが表示されるはずです。

  1. ブラウザをリロードしてフォームをまっさらにする(名前や年齢は入力しない)
  2. メールアドレスに 「hoge@ex.com」 を入力
  3. メールアドレス(確認用)に 「moge@ex.com」 を入力
  4. 送信ボタンをクリック(fullName などのバリデーションエラー発生)
  5. メールアドレス関連以外の項目を入力
  6. 再度、送信ボタンをクリック

メールアドレスが一致しませんのメッセージが表示された
「メールアドレスが一致しません」のメッセージが表示された

これは "5. メールアドレス関連以外の項目を入力" の操作にて z.object("fullNameやageなど") のエラー発生しなかったためです。
そのため処理は中断されず .refine("メールアドレスの一致を確認") の処理が実行され、その結果メッセージが表示されています。

このままでもバリデーション自体は問題なく動作していますがエラーメッセージの表示タイミングに一貫性がないのはちょっと気になりますよね。
※ユーザーは必ずしもフォームの上から順に入力するとは限らない

そこで処理の実行順を調整してどこから操作してもちゃんとエラーメッセージが出るように修正したいと思います。

グループ化して z.object 内へ移動 👨‍👩‍👦

下記のように z.object + .refine と 2 つに分かれているような定義が主な要因です。

const FormSchema = z
  .object("fullName や age など")
  .refine("メールアドレスの一致を確認");

そのため、"email と confirmEmail と refine をグループ化して z.object 内へ移動させます"

emails グループを作成

まずは z.object 直下にある "email"と"confirmEmail"を削除 します。
続いて z.objectの後の .refine も削除します。

const FormSchema = z.object({
  fullName: z
    .string({
      required_error: "名前を入力してください",
      invalid_type_error: "入力が正しくないようです",
    })
    .min(2, { message: "2文字以上入力してください" }),

  〜 省略 〜

  birthday: z
    .string({ required_error: "生年月日を入力してください" })
    .pipe(z.coerce.date()),
- email: z
-   .string({
-     required_error: "メールアドレスを入力してください",
-   })
-   .email({ message: "有効なメールアドレスを入力してください" }),
- confirmEmail: z
-   .string({
-     required_error: "確認用メールアドレスを入力してください",
-   })
-   .email({ message: "有効なメールアドレスを入力してください" }),

  〜 省略 〜

  agree: z.coerce.boolean().refine((val) => val === true, {
    message: "利用規約に同意する必要があります",
  }),
})
- .refine((data) => data.email === data.confirmEmail, {
-  message: "メールアドレスが一致しません",
-  path: ["confirmEmail"], // エラーをconfirmEmailフィールドに関連付けそこにエラーメッセージを表示する
- });

そして、"agree"の下に"emails"グループを追加します。
( わかりやすく agree の下にしてますが FormSchema = z.object 内であれば問題ありません )

const FormSchema = z.object({
  fullName: z
    .string({
      required_error: "名前を入力してください",
      invalid_type_error: "入力が正しくないようです",
    })
    .min(2, { message: "2文字以上入力してください" }),

  〜 省略 〜

  agree: z.coerce.boolean().refine((val) => val === true, {
    message: "利用規約に同意する必要があります",
  }),
+  emails: z
+    .object({
+      email: z
+        .string({
+          required_error: "メールアドレスを入力してください",
+        })
+        .email({ message: "有効なメールアドレスを入力してください" }),
+      confirmEmail: z
+        .string({
+          required_error: "確認用メールアドレスを入力してください",
+        })
+        .email({ message: "有効なメールアドレスを入力してください" }),
+    })
+    .refine((data) => data.email === data.confirmEmail, {
+      message: "メールアドレスが一致しません",
+      path: ["confirmEmail"]
+    }),
});

続いてスキーマの構造が変わった(ネストありになった)ので、そのままだとエラーが出てしまいます。全体を修正します。

初期値に "emails" を追加

初期値に emails を追加します。

  const [formData, setFormData] = useState<FormData | {}>({
    spouse: 1,
    agree: true,
+   emails: {},
  });

エラー用 State "errors" の修正

エラー内容を管理する State errors も変更が必要です。
スキーマの構造が変わったことで型も <z.ZodFormattedError<FormData> | null>に変更し初期値は "null" にしておきます。

同時に type FormErrors は不要になったので削除しておきます。

type FormData = z.infer<typeof FormSchema>;
- type FormErrors = { [K in keyof FormData]?: string[] };

- const [errors, setErrors] = useState<FormErrors>({});
+ const [errors, setErrors] = useState<z.ZodFormattedError<FormData> | null>(null);

onChange と onSubmit 関数を修正

handleChangehandleSubmit も修正します。

  /** onChange */
  const handleChange = (
    e: React.ChangeEvent<
      HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
    >
  ) => {
    const { id, name, value, type } = e.target;
    const fieldName = id || name;

+    // type="email"の場合のstate更新処理
+   if (type === "email") {
+     setFormData((prev: FormData) => ({
+       ...prev,
+       emails: {
+         ...prev.emails,
+         [fieldName]: value,
+       },
+     }));
+   }
    // type="checkbox"の場合はチェックされているかを判定する
    else if (type === "checkbox") {

      ~ 省略 ~

    // その他の処理
    else {
      setFormData((prev) => ({ ...prev, [fieldName]: value }));
    }
  };

  /** onSubmit */
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {

    ~ 省略 ~

    } catch (error) {
      if (error instanceof ZodError) {
-       console.log(error.formErrors.fieldErrors);
-       setErrors(error.formErrors.fieldErrors);

+       // エラーオブジェクトをフォーマット
+       console.log(error.format());
+       setErrors(error.format());
      }
    }
  };

エラーメッセージを修正する

<span className="error-message"> で表示しているエラーメッセージも修正します。errors ステートの階層が深くなりオプショナルになったので条件分岐とオプショナルチェーンで繋いでいきます。最終的にエラーメッセージは _errors[0] に格納されています。

   /** fullName */
-  {errors.fullName && (
-    <span className="error-message">{errors.fullName[0]}</span>
-  )}

+  {errors?.fullName?._errors && (
+    <span className="error-message">{errors.fullName._errors[0]}</span>
+  )}

   /** age */
-  {errors.age && (
-    <span className="error-message">{errors.age[0]}</span>
-  )}

+  {errors?.age?._errors && (
+    <span className="error-message">{errors.age._errors[0]}</span>
+  )}

    /** gender, birthday, url, spouse, comment, agreeも同様の修正のため割愛します */

emailconfirmEmail はネストされているので下記のようになります。

  /** メールアドレス */
- {errors.email && (
-     <span className="error-message">{errors.email[0]}</span>
- )}

+  {errors?.emails?.email?._errors && (
+     <span className="error-message">{errors.emails.email._errors[0]}</span>
+  )}

  /** メールアドレス(確認用) */
- {errors.confirmEmail && (
-     <span className="error-message">{errors.confirmEmail[0]}</span>
- )}

+  {errors?.emails?.confirmEmail?._errors && (
+     <span className="error-message">{errors.emails.confirmEmail._errors[0]}</span>
+  )}

これで修正は完了です。npm run dev を実行して動作チェックしてみましょう。

npm run dev

確認のための操作手順は下記になります。

  1. ブラウザをリロードしてフォームをまっさらにする(名前や年齢は入力しない)
  2. メールアドレスに 「hoge@ex.com」 を入力
  3. メールアドレス(確認用)に 「moge@ex.com」 を入力
  4. 送信ボタンをクリック

下記のように"名前"や"年齢"などのエラーメッセージと同じタイミングで 「メールアドレスが一致しません」 も表示されていることが確認できると思います。

同じタイミングでエラーメッセージ「メールアドレスが一致しません」が表示
同じタイミングでエラーメッセージ「メールアドレスが一致しません」が表示

これでどの項目から入力してもエラーメッセージがちゃんと表示されるので少しだけユーザーフレンドリーにできたかと思います。

修正後の全体のソースは下記になります。

"use client";
import { useState } from "react";
import { z, ZodError } from "zod";

const FormSchema = z.object({
  fullName: z
    .string({
      required_error: "名前を入力してください",
      invalid_type_error: "入力が正しくないようです",
    })
    .min(2, { message: "2文字以上入力してください" }),
  age: z
    .number({
      required_error: "年齢を入力してください",
      invalid_type_error: "半角数字で入力してください",
    })
    .min(1, { message: "1以上を入力してください" })
    .int({ message: "年齢は正数で入力してください" }),
  gender: z.enum(["", "female", "male", "other"], {
    required_error: "性別を選択してください",
  }),
  birthday: z
    .string({ required_error: "生年月日を入力してください" })
    .pipe(z.coerce.date()),
  url: z.string().url({ message: "有効なURLを入力してください" }).optional(),
  spouse: z.number({
    required_error: "選択してください",
    invalid_type_error: "入力形式が正しくありません",
  }),
  comment: z
    .string()
    .min(10, { message: "コメントは10文字以上で入力してください" })
    .max(30, { message: "コメントは30文字以内で入力してください" })
    .optional(),
  agree: z.coerce.boolean().refine((val) => val === true, {
    message: "利用規約に同意する必要があります",
  }),
  emails: z
    .object({
      email: z
        .string({
          required_error: "メールアドレスを入力してください",
        })
        .email({ message: "有効なメールアドレスを入力してください" }),
      confirmEmail: z
        .string({
          required_error: "確認用メールアドレスを入力してください",
        })
        .email({ message: "有効なメールアドレスを入力してください" }),
    })
    .refine((data) => data.email === data.confirmEmail, {
      message: "メールアドレスが一致しません",
      path: ["confirmEmail"], // エラーをconfirmEmailフィールドに関連付けそこにエラーメッセージを表示する
    }),
});

type FormData = z.infer<typeof FormSchema>;

export default function Home() {
  const [formData, setFormData] = useState<FormData | {}>({
    spouse: 1,
    agree: true,
    emails: {},
  });

  const [errors, setErrors] = useState<z.ZodFormattedError<FormData> | null>(
    null
  );

  const handleChange = (
    e: React.ChangeEvent<
      HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
    >
  ) => {
    const { id, name, value, type } = e.target;
    const fieldName = id || name;

    // クロスフィールドバリデーション(メールアドレス)
    if (type === "email") {
      setFormData((prev: FormData) => ({
        ...prev,
        emails: {
          ...prev.emails,
          [fieldName]: value,
        },
      }));
    }
    // type="checkbox"の場合はチェックされているかを判定する
    else if (type === "checkbox") {
      const _checkbox = e.target as HTMLInputElement;
      const _isChecked = _checkbox.checked; // boolean
      setFormData((prev) => ({ ...prev, [fieldName]: _isChecked }));
    }
    // valueが "" の場合はブラウザの初期値に戻す
    else if (value === "") {
      setFormData((prev) => ({ ...prev, [fieldName]: undefined }));
    }
    // valueが半角数字の場合はnumber型に変換(デフォルトでは "string" になるため)
    else if (/^[0-9]+$/.test(value)) {
      setFormData((prev) => ({ ...prev, [fieldName]: Number(value) }));
    }
    // その他の処理
    else {
      setFormData((prev) => ({ ...prev, [fieldName]: value }));
    }
  };

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

    try {
      // バリデーションに成功した場合の処理
      FormSchema.parse(formData);
      console.log(formData);
    } catch (error) {
      if (error instanceof ZodError) {
        // エラーオブジェクトをフォーマット
        console.log(error.format());
        setErrors(error.format());
      }
    }
  };

  return (
    <main>
      <form onSubmit={handleSubmit} className="form" noValidate>
        <div className="form-group">
          <label htmlFor="fullName">名前</label>
          <div>
            <input id="fullName" type="text" onChange={handleChange} />
            {errors?.fullName?._errors && (
              <span className="error-message">
                {errors.fullName._errors[0]}
              </span>
            )}
          </div>
        </div>

        <div className="form-group">
          <label htmlFor="age">年齢</label>
          <div>
            <input id="age" type="text" onChange={handleChange} />
            {errors?.age?._errors && (
              <span className="error-message">{errors.age._errors[0]}</span>
            )}
          </div>
        </div>

        <div className="form-group">
          <label htmlFor="gender">性別</label>
          <div>
            <select id="gender" onChange={handleChange}>
              <option value="">選択してください</option>
              <option value="male">男性</option>
              <option value="female">女性</option>
              <option value="other">無回答</option>
            </select>
            {errors?.gender?._errors && (
              <span className="error-message">{errors.gender._errors[0]}</span>
            )}
          </div>
        </div>

        <div className="form-group">
          <label htmlFor="birthday">生年月日</label>
          <div>
            <input id="birthday" type="date" onChange={handleChange} />
            {errors?.birthday?._errors && (
              <span className="error-message">
                {errors.birthday._errors[0]}
              </span>
            )}
          </div>
        </div>

        <div className="form-group">
          <label htmlFor="email">メールアドレス</label>
          <div>
            <input id="email" type="email" onChange={handleChange} />
            {errors?.emails?.email?._errors && (
              <span className="error-message">
                {errors.emails.email._errors[0]}
              </span>
            )}
          </div>
        </div>

        <div className="form-group">
          <label htmlFor="confirmEmail">メールアドレス(確認用)</label>
          <div>
            <input id="confirmEmail" type="email" onChange={handleChange} />
            {errors?.emails?.confirmEmail?._errors && (
              <span className="error-message">
                {errors.emails.confirmEmail._errors[0]}
              </span>
            )}
          </div>
        </div>

        <div className="form-group">
          <label htmlFor="url">URL</label>
          <div>
            <input id="url" type="text" onChange={handleChange} />
            {errors?.url?._errors && (
              <span className="error-message">{errors.url._errors[0]}</span>
            )}
          </div>
        </div>

        <div className="form-group">
          <label>配偶者</label>
          <div className="radio-group">
            <label>
              <input
                name="spouse"
                type="radio"
                value="1"
                defaultChecked // 初期表示で選択状態にする。値は渡せない。
                onChange={handleChange}
              />
              あり
            </label>
            <label>
              <input
                name="spouse"
                type="radio"
                value="0"
                onChange={handleChange}
              />
              なし
            </label>
            {errors?.spouse?._errors && (
              <span className="error-message">{errors.spouse._errors[0]}</span>
            )}
          </div>
        </div>

        <div className="form-group">
          <label htmlFor="comment">自己紹介</label>
          <div>
            <textarea id="comment" onChange={handleChange}></textarea>
            {errors?.comment?._errors && (
              <span className="error-message">{errors.comment._errors[0]}</span>
            )}
          </div>
        </div>

        <div className="form-group">
          <div>
            <label>
              <input
                id="agree"
                type="checkbox"
                defaultChecked // 初期表示でチェックありの状態にする。値は渡せない。
                onChange={handleChange}
              />
              <span>利用規約に同意する</span>
            </label>
            {errors?.agree?._errors && (
              <span className="error-message">{errors.agree._errors[0]}</span>
            )}
          </div>
        </div>
        <div className="button-group">
          <button type="submit">送信</button>
        </div>
      </form>
    </main>
  );
}

終わりに 🙇‍♂️

個人的主観ですが、Zod は TypeScript ファーストの思想に基づいてるのでフォームのバリデーションで使うのはちょっと癖があるかなーっと感じました。もしからしたらYupとかの方が使いやすいかもしれません(機会があれば比較してみます)。

以上で、"Zod と HTML だけでバリデーションをやってみた。react-hook-form 未使用" の記事は終わりです。最後までお読みいただき、ありがとうございました。この記事が皆様のお役に立てば幸いです。

Discussion