本記事のゴール

Next.jsプロジェクトに、サクッとバリデーション処理を追加したい。でき上がりのサンプルプロジェクトは下記にあります。

作業

今回は友人から zodがいいぞ、という話を聞いていたので迷わず試してみようと思いました。

今回は、

という条件で進めてみます。zodのドキュメントBasic Usageあたりをヒントに使います。

今回のサンプルプロジェクトはNext.js 13で作られていまして、エントリーポイントはsrc/apps/page.tsxです。こちらにゴリゴリフロントエンドに相当する処理を作っていきます。

zodをinstall

以下はcleanインストールから作成する時のみの手順です。まず、Next.jsのプロジェクトルートで以下を打ちます。

yarn add zod react-hook-form @hookform/resolvers

最後の@hookform/resolversは、react-hook-formでzodを利用するために必ず必要なのでもれなくインストールしてください。

インストールが完了したら、ルートで

yarn run dev

を実行し、開発環境を立ち上げます。

  1. 対象のComponentでライブラリをインポート。
  2. バリデーション定義をzodで書く。
  3. フォーム管理をreact-hook-formに一任する処理を書く。
  4. Next.jsのjsxで書かれたフォーム処理に、react-hook-formのバリデーション処理、エラー表示処理を追加していく。

という流れとなります。

完成したソースコードのサンプルを以下に書きます(前述の1〜4の手順をコメントで追加していきます。)

1.対象のComponentでライブラリをインポート。
src/apps/page.tsx
"use client";

import { FieldValues, SubmitHandler, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { useState } from 'react';
2.バリデーション定義をzodで書く。

下記は、

を行なっていますが、要件に合わせて改変してください。

PHP系フレームワーク(バックエンドで私がよく使う)のこういうバリデーション処理って、retype_passwordのバリデーション定義で一致もそのまま書けるイメージがありますが、Zodでは refine()というメソッドを後付けで追加してバリデーションします。少し癖があるので要注意です。

src/apps/page.tsx
const schema = z.object({
  email: z.string().email({ message: "メールアドレスを正しく入力してください" }),
  password: z.string().min(8, "パスワードを8文字以上で入力してください").max(20, "パスワードを20文字以下で入力してください"),
  retype_password: z.string().min(8, "パスワードを8文字以上で入力してください").max(20, "パスワードを20文字以下で入力してください")
})
  .refine((value) => value.password === value.retype_password, {
    message: "パスワードが一致しません",
    path: ['retype_password']
  })
3.フォーム管理をreact-hook-formに一任する処理を書く。

ここはいわゆるおまじないです。react-hook-formは通常のhtmlに、バリデーション処理を簡便に注入するライブラリでして、そのバリデーションの成否を判定するリソルバは別途設定せねばなりません。Zod以外にも、YupSuperstructなどさまざまなリソルバが用意されているようです。もし興味があれば、以下のリンクを参照してみてください。

私の方では、公式ドキュメントに載っている一般的なやり方で実装していきます。

src/apps/page.tsx
const Page = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(schema),
  });

  const [loading, setLoading] = useState(false) // ロード処理

  const onSubmit: SubmitHandler<FieldValues> = (values) => {

    setLoading(true)

    // こちらで、APIへのポストなどを行う

    setLoading(false)

    // alertで簡便に出力しているが、本来は、適宜要件に合わせてUI処理する
    alert("処理を完了しました。")
  }
  
  return (
    // ここは4.の章でかく
  )
}
4.Next.jsのjsxで書かれたフォーム処理に、react-hook-formのバリデーション処理、エラー表示処理を追加していく。

あとはゴリゴリreact-hook-formの注入処理をjsxに入れていくのみです。(重複するので「完全なコード」の欄で書きます。)

完全なコード

最終的に、Next.jsプロジェクトのエントリーポイントとなるsrc/app/page.tsxのソースコードは以下となります。先述の通りNext 13プリインのtailwindcssをそのまま使っているので、1ファイルでほぼ処理が完結しています。

src/apps/page.tsx
"use client";

import { FieldValues, SubmitHandler, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { useState } from 'react';

const schema = z.object({
  email: z.string().email({ message: "メールアドレスを正しく入力してください" }),
  password: z.string().min(8, "パスワードを8文字以上で入力してください").max(20, "パスワードを20文字以下で入力してください"),
  retype_password: z.string().min(8, "パスワードを8文字以上で入力してください").max(20, "パスワードを20文字以下で入力してください")
})
  .refine((value) => value.password === value.retype_password, {
    message: "パスワードが一致しません",
    path: ['retype_password']
  })

const Page = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(schema),
  });
  const [loading, setLoading] = useState(false)

  const onSubmit: SubmitHandler<FieldValues> = (values) => {

    setLoading(true)

    // こちらで、APIへのポストなどを行う

    setLoading(false)

    // alertで簡便に出力しているが、本来は、適宜要件に合わせてUI処理する
    alert("処理を完了しました。")
  }

  return (
    <main className="m-10 p-5 bg-white">
      <form onSubmit={handleSubmit(onSubmit)}>
        <h2 className="text-xl font-bold leading-loose pt-6">Zodの動作確認を行うサンプルプロジェクトです。</h2>

        <div className="pt-6">

          <h5 className="font-bold">メールアドレス</h5>

          <div className="py-2 w-full flex">
            <input {...register('email')} className={"w-full px-6 py-4 rounded-md text-xl" + (errors.email ? " border-red-500 border-2" : " border")} placeholder="メールアドレスを入力" />
          </div>
          {errors.email && <div className="text-red-500">{errors?.email?.message?.toString()}</div>}
        </div>

        <div className="pt-6">
          <h5 className="font-bold">パスワード</h5>
          <div className="py-2 w-full flex">
            <input
              {...register('password')}
              className={"w-full px-6 py-4 rounded-md text-xl" + (errors.password ? " border-red-500 border-2" : " border")}
              type="password"
              placeholder="パスワードを入力してください"
            />
          </div>
          {errors.password && <div className="text-red-500">{errors?.password?.message?.toString()}</div>}
        </div>
        <div className="pt-6">
          <h5 className="font-bold">パスワード(確認)</h5>
          <div className="py-2 w-full flex">
            <input
              {...register('retype_password')}
              className={"w-full px-6 py-4 rounded-md text-xl" + (errors.retype_password ? " border-red-500 border-2" : " border")}
              type="password"
              placeholder="パスワード(確認)を入力してください"
            />
          </div>
          {errors.retype_password && <div className="text-red-500">{errors?.retype_password?.message?.toString()}</div>}
        </div>

        <div className="w-full pt-12 pb-6">
          <button type="submit" className="w-full bg-blue-300 hover:bg-blue-200 text-gray-700 font-semibold px-4 py-4 rounded-md">
            送信
          </button>
        </div>


      </form>
    </main >

  );
};

export default Page

バリデーションの逆引き一覧

以下に、私のほうで実際に使ったさまざまなバリデーションパターンを逆引き形式で、気づき次第追記していきます。

URLのバリデーションをするには?

  url: z.string().url({ message: 'URLを入力してください' }),

実際に試したところ、http://またはhttps://が入力されているかどうかが条件で、.comなどのドメイン形式となっているかどうかは 判定条件ではないようです。よって、ドメインのバリデーションが必要な場合 は以下のように正規表現を使います。

    .regex(/^https?:\/\/(.+?)\.(.+?)/, { message: 'URLを入力してください' }),

相対パスのバリデーションをするには?

./で始まるURLのバリデーションをするには以下のように正規表現を使います。./hogehoge./hogehoge/fugafugaといった多階層の相対パスも一致します。

  path: z.string().regex(/^\.\/(.*)/, { message: 'パスを相対パスの形式で入力してください。' }),

ちなみに絶対パスのバリデーションをしたい時も大きな変更はないです。\.を取るだけです。

  path: z.string().regex(/^\/(.*)/, { message: 'パスを絶対パスの形式で入力してください。' }),

値が数値のセレクトボックスのバリデーションをするには?

以下のようにします。ポイントは、valueAsNumberをオプションで指定し、数字としてzodで判定するようにすること。

<select name="pref" {...register('pref',{ valueAsNumber: true })}>
  <option value={0}>未選択</option>
  <option value={1}>北海道</option>
  <option value={2}>青森県</option>
  // 中略
  </option value={47}>沖縄県</option>
</select>

こうすることで、未選択を0として扱い、1以上をバリデーション通過というシンプルな形で判定が可能です。

 pref: z.number().min(1, { message: '都道府県を選択してください' }),

日本のハイフン付き電話番号のバリデーションをするには?

日本のハイフン付きの電話番号 の場合ですと、

といった電話番号以外にも複雑なパターンがありそうですが、問い合わせなどのケースを考えて、一般的な電話番号を対象に緩めにバリデーションしておきます。

contact_tel: z.string().nullable()
    .refine((value) => {
      const phoneNumberPattern = /^0\d{2,3}-\d{3,4}-\d{3,4}$/; //

// nullまたは空文字列の場合は検証をスキップ
      if (value === null || value === '') {
        return true;
      }

      return phoneNumberPattern.test(value);
    }, { message: '正しい電話番号を入力してください' }),

(もう少し厳しいバリデーションルールにニーズがありそうならば、記事化します)

利用規約などチェックボックスのバリデーションをするには?

下記のようなチェックボックスタイプのパーツは、

<div className="flex items-start mb-1">
  <div className="flex items-center h-5">
    <input id="term" type="checkbox" {...register('term', { required: true })} value="" className={border border-gray-300 w-4 h-4 rounded bg-gray-50"} />
  </div>
  <label htmlFor="term" className="ms-2 text-sm font-medium text-gray-900 dark:text-gray-300"><Link href="/term" className="text-blue-600 hover:underline dark:text-blue-500">ご利用規約</Link>に同意する</label>
</div>

このように、refine()を使って判定を行います。

  term: z.boolean().refine((value) => value === true, { message: '注意事項・利用規約をお読みいただき同意してください。' }),

まとめ

Next.js + react-hook-form + zodの組み合わせで、一度雛形を作ってしまえばform処理をすごいスピード(Ruby on RailsやLaravelなどの古典的なバック・フロント同梱のフレームワークと比べ)で量産できます。 ただ、DBと連関する処理や認証処理などは別途用意せねばなりませんので、用途によってはという注意書きはつきますが、少なくとも初学者などには非常に向いているソリューションだと思いました。

補足: 本ブログのNext.js関連記事

※ 2023.11.19 追記

ブログを色々書き進めて、React/Next.js関連の記事がちょっと増えてきたので、こちらに併記させていただきます。もしよろしければお読みくださいませ。



以上でございます。


この記事が何かのお役に立てれば幸いです。最後までお読みいただきありがとうございました!