T.Ichikawaをフォローする

【React】React Hook Form + Zod を組み合わせてバリデーション付き入力フォームを作ってみた

フロントエンド

はじめに

React Hook Fromとは?Zodとは?

過去にブログを記載してますのでよろしければご高覧ください!

作ってみよう

例として、下記に記載した機能のバリデーション付き入力フォームを作成します。
バリデーションは各要素のonChangeイベントと、 ボタン押下のonClickイベントの際に行うようにします。
先に実装例を紹介し、解説を後で行います。

以降、React Hook FromをRHFと記載させていただきます。

入力フォームの機能

項目名要素入力制限
氏名テキストボックス必須
100文字以内
メールアドレステキストボックス必須
100文字以内
メールアドレス形式
職業セレクトボックス必須

完成系

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

const schema = z.object({
  name: z.string().nonempty({ message: '氏名を入力してください。' }).max(100),
  mail: z
    .string()
    .nonempty({ message: 'メールアドレスを入力してください。' })
    .max(100)
    .email({ message: 'メールアドレス形式で入力してください。' }),
  occupation: z.string().nonempty({ message: '職種を選択してください。' }),
});

type ValidateType = z.infer<typeof schema>;

const InputForm = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    defaultValues: {
      name: '',
      mail: '',
      occupation: '',
    },
    mode: 'onChange',
    resolver: zodResolver(schema),
  });

  const onSubmit = handleSubmit(() => {
    alert('成功');
  });

  return (
    <div>
      <div>
        <label>氏名</label>
        <input  type="text" {...register('name')} />
        {errors.name && <span>{errors.name.message}</span>}
      </div>
      <div>
        <label>メールアドレス</label>
        <input type="text" {...register('mail')} />
        {errors.mail && <span>{errors.mail.message}</span>}
      </div>
      <div>
        <label>職業</label>
        <select {...register('occupation')}>
          <option value="" />
          <option value="1">会社員</option>
          <option value="2">学生</option>
          <option value="3">その他</option>
        </select>
        {errors.occupation && <span>{errors.occupation.message}</span>}
      </div>
      <div>
        <button onClick={onSubmit}>確定</button>
      </div>
    </div>
  );
};

export default InputForm;

解説

最初にバリデーションルールをZodのオブジェクトスキーマにてにて定義します。
バリデーション毎に任意のエラーメッセージを設定することも可能です。

const schema = z.object({
  name: z.string().nonempty({ message: '氏名を入力してください。' }).max(100),
  mail: z
    .string()
    .nonempty({ message: 'メールアドレスを入力してください。' })
    .max(100)
    .email({ message: 'メールアドレス形式で入力してください。' }),
  occupation: z.string().nonempty({ message: '職種を選択してください。' }),
});

TypeScriptなどで型を使用したい場合は、z.inferを使用してスキーマから型定義を取得可能です。

type ValidateType = z.infer<typeof schema>;

続いて、RHFのuseFromにてバリデーションを設定します。
Zodと組み合わせる場合は、「resolver」の設定が必須となります。
useFromのresolverにzodResolverメソッドを使用して引数にZodのスキーマを指定します。
「mode」にはonChangeを設定していますが、未設定の場合はonSubmitが設定されます。
「defaultValues」は初期データになります。
各プロパティに値をしていると入力エリアに反映された状態で表示されます。

const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    defaultValues: {
      name: '',
      mail: '',
      occupation: '',
    },
    mode: 'onChange',
    resolver: zodResolver(schema),
  });

最後に各要素と上記で設定したバリデーションを紐づけます。
inputタグやselectタグに、useFromのregisterを使用しプロパティ名を指定して紐づけます。
useFromのformStateがフォームの状態を管理する値となります。
エラーメッセージを表示するため、formState.errorsを使用し各入力欄のエラーメッセージを表示させてます。

// 氏名:スキーマの「name」を設定する 
<input  type="text" {...register('name')} />
// エラーメッセージの表示 
{errors.name && <span>{errors.name.message}</span>}

// メールアドレス:スキーマの「mail」を設定する 
<input type="text" {...register('mail')} />
// エラーメッセージの表示 
{errors.mail && <span>{errors.mail.message}</span>}

// 職業:スキーマの「occupation」を設定する 
<select {...register('occupation')}>
  <option value="" />
  <option value="1">会社員</option>
  <option value="2">学生</option>
  <option value="3">その他</option>
</select>
// エラーメッセージの表示 
{errors.occupation && <span>{errors.occupation.message}</span>}

ボタン押下時の処理に「handleSubmit」を使用しています。
バリデーション結果が正常場合にのみ、処理を実行させることができます。

const onSubmit = handleSubmit(() => {
  alert('成功');
});

<button onClick={onSubmit}>確定</button>

正常パターンと異常パターンで処理を分岐させることも可能です。
関数にhandleSubmitをラップせず、関数内にて記載することで処理を分岐可能です。

const onSubmit = () => {
  // バリデーション結果が正常な場合の処理 
  handleSubmit(  () => {
    alert('成功');
  },
  // バリデーション結果が異常な場合の処理 
  () => {
      alert('異常');
    },
  )();
};

共通化について

先程は1つのファイルにまとめて記載しましたが、入力フォームが登場する画面は多いと思います。
各ファイルにinputタグやエラーメッセージを毎回記載するのは面倒ですよね。
inputやselectの要素をコンポーネント分割して共通化させた方が効率が良いと思います。
試しに、今回のuserFormのメソッドを使用して共通化したものを記載します。

完成系

input要素(今回はテキストボックス)

import { type FieldValues, type UseFormProps } from 'react-hook-form';

const ZodInputAtom = <T extends FieldValues>(props: UseFormProps<T> & { message?: string }) => {
  const { message, ...rest } = props;

  return (
    <>
      <input type="text" {...rest} />
      {message && <span style={{ color: 'red' }}>{message}</span>}
    </>
  );
};

export default ZodInputAtom;

select要素

import { type FieldValues, type UseFormProps } from 'react-hook-form';

type SelectBoxOption = {
  label: string;
  value: string;
};

export type ZodSelectBoxProps<T extends FieldValues> = UseFormProps<T> & {
  options: SelectBoxOption[];
  message?: string;
};

const ZodSelectBox = <T extends FieldValues>(props: ZodSelectBoxProps<T>) => {
  const { options, message, ...rest } = props;

  return (
    <>
      <select {...rest}>
        <option value="" />
        {options.map((data) => {
          return (
            <option key={data.value} value={data.value}>{data.label}</option>
          );
        })}
      </select>
      {message && <span>{message}</span>}
    </>
  );
};

export default ZodSelectBox;

終わりに

useFromとzodを組み合わせて入力フォームを作成してみました!
ただ、入力フォームは多くの画面で使用することが多いと思いますのでなるべく共通化できた方が良いと思います。
registerやformStateを使用して共通化は可能ですが、個人的にはあまり適していないと思います。
RHFにはuseControllerという共通化に適したカスタムフックがあります。
なので次回はuseControllerを使用した共通コンポーネントの作り方を紹介しようと思います。