🪝

React Hook Formで複数項目を同時にバリデーションする方法

2024/10/28に公開

はじめに

React Hook Formは、シンプルで強力なバリデーション機能を提供してくれる素晴らしいライブラリです。
フォームの管理が容易になり、開発者にとって非常に便利なツールとなっています。
しかし、複数項目を同時にバリデーションする際は、少し手間がかかることもあります。
そこで、今回は「年月日」の項目を用いて、複数項目を同時にバリデーションする方法を紹介します✨

また、もしこの記事よりも良い方法があればコメントで教えてください🙇

完成系

まずは、完成したコードをご覧ください。
このコードは、ユーザーが生年月日を選択する際のバリデーションを実装しています。
各項目にバリデーションが設定されており、正しい値が選択されない限り、エラーが表示される仕組みになっています。
次のセクションでは、抑えておくべきポイントについて説明していきます。

完成系
import React from 'react';
import { isValid, parse } from 'date-fns';
import { useForm } from 'react-hook-form';

type FormData = {
  year: string;
  month: string;
  day: string;
};

export const BirthDateForm = () => {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors },
    setError,
    clearErrors,
  } = useForm<FormData>({
    mode: 'onBlur',
  });

  const onSubmit = (data: FormData) => {
    console.log(data);
  };

  const dateChecker = () => {
    const year = watch('year');
    const month = watch('month');
    const day = watch('day');
    // 値が存在しているかどうかを確認
    if (!year || !month || !day) return true;

    // 日付が有効かどうかを確認
    const isValidDate = parse(`${year}-${month}-${day}`, 'yyyy-M-d', new Date());
    if (isValid(isValidDate)) return true;

    // 対象の項目にエラーを設定
    setError('year', { type: 'dateChecker', message: '存在しない日付です' });
    setError('month', { type: 'dateChecker' });
    setError('day', { type: 'dateChecker' });

    return false;
  };

  // エラーをクリア
  const ERROR_KEYS: (keyof FormData)[] = ['year', 'month', 'day'];
  const clearDateErrors = () => {
    if (ERROR_KEYS.every((key) => errors[key]?.type === 'dateChecker')) {
      clearErrors(ERROR_KEYS);
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="m-lg">
      <div className="flex items-center">
        <div className="h-10">
          <select
            className={`border rounded ${errors.year ? 'border-[#CE0023]' : ''}`}
            {...register('year', {
              required: '年は必須です',
              validate: {
                dateChecker: () => dateChecker() || '存在しない日付です',
              },
              onChange: clearDateErrors,
            })}
          >
            <option value="">選択してください</option>
            {/* ここでは例として2000年から2024年までを選べるようにしています */}
            {[...Array(25)].map((_, index) => {
              const year = 2000 + index;
              return (
                <option key={year} value={year}>
                  {year}
                </option>
              );
            })}
          </select>
          {errors.year && <p className="text-[#CE0023]">{errors.year.message}</p>}
        </div>
        <span className="h-10 mx-sm"></span>
        <div className="h-10">
          <select
            className={`border rounded ${errors.month ? 'border-[#CE0023]' : ''}`}
            {...register('month', {
              required: '月は必須です',
              validate: {
                dateChecker: () => dateChecker(),
              },
              onChange: clearDateErrors,
            })}
          >
            <option value="">選択してください</option>
            {[...Array(12)].map((_, index) => (
              <option key={index + 1} value={index + 1}>
                {index + 1}
              </option>
            ))}
          </select>
          {errors.month && <p className="text-[#CE0023]">{errors.month.message}</p>}
        </div>
        <span className="h-10 mx-sm"></span>
        <div className="h-10">
          <select
            className={`border rounded ${errors.day ? 'border-[#CE0023]' : ''}`}
            {...register('day', {
              required: '日は必須です',
              validate: {
                dateChecker: () => dateChecker(),
              },
              onChange: clearDateErrors,
            })}
          >
            <option value="">選択してください</option>
            {[...Array(31)].map((_, index) => (
              <option key={index + 1} value={index + 1}>
                {index + 1}
              </option>
            ))}
          </select>
          {errors.day && <p className="text-[#CE0023]">{errors.day.message}</p>}
        </div>
        <span className="h-10 mx-sm"></span>
      </div>
      <button type="submit" className="mt-sm">
        送信
      </button>
    </form>
  );
};

抑えるべきポイント

各項目の値が正常である時にバリデーションチェックを行う

registervalidate オプションでは、カスタムバリデーション関数を設定できます。
この validate は、他のルール(例えば required)には依存せずに独立して実行されます。

コールバック関数を引数として渡して1つの条件をバリデートすることも、複数のコールバック関数のオブジェクトを渡して、それぞれ独立にバリデートすることもできます。これらの関数は required や他のバリデーションルールに依存せずに単独で実行されます。

https://react-hook-form.com/docs/useform/register

そのため、validate は他のバリデーションでエラーが発生している場合でも実行される可能性があります。
競合を防ぎ、正常に動作させるためには、各項目の値がエラーでない場合のみ validate を実行させます。

  const dateChecker = () => {
    const year = watch('year');
    const month = watch('month');
    const day = watch('day');
    // 値が存在しているかどうかを確認
    if (!year || !month || !day) return true;
  };

エラー時のUI対応を適切に行う

エラーが発生した場合、該当するすべての項目をエラー用のUIに切り替える必要があります。
そうしないと、個別にバリデーションを実行しない限り、UIがエラー表示に変わらないためです。
以下のようにsetErrorで各項目をエラー状態に切り替えてUIを反映させます。

  const dateChecker = () => {
    const year = watch('year');
    const month = watch('month');
    const day = watch('day');
    // 値が存在しているかどうかを確認
    if (!year || !month || !day) return true;

    // バリデーションチェック
    // 日付が有効かどうかを確認
    const isValidDate = parse(`${year}-${month}-${day}`, 'yyyy-M-d', new Date());
    if (isValid(isValidDate)) return true;

    // 対象の項目にエラーを設定
    setError('year', { type: 'dateChecker', message: '存在しない日付です' });
    setError('month', { type: 'dateChecker' });
    setError('day', { type: 'dateChecker' });

    return false;
  };

setErroronSubmitの停止には使えない

実は、setErrorを使ってもonSubmitの実行を停止することはできません。
そのため、バリデーションを設定する場合には、validate関数でfalseまたはエラーメッセージを返す必要があります。
これにより、エラーが検出された際にonSubmitが正しく中断されます。

https://github.com/react-hook-form/react-hook-form/issues/226

          <select
            className={`border rounded ${errors.year ? 'border-[#CE0023]' : ''}`}
            {...register('year', {
              required: '年は必須です',
              validate: {
                // errorである場合、エラーメッセージを返す
                dateChecker: () => dateChecker() || '存在しない日付です',
              },
            })}
          >


          <select
            className={`border rounded ${errors.month ? 'border-[#CE0023]' : ''}`}
            {...register('month', {
              required: '月は必須です',
              validate: {
                // errorである場合、falseを返す
                dateChecker: () => dateChecker(),
              },
            })}
          >


          <select
            className={`border rounded ${errors.day ? 'border-[#CE0023]' : ''}`}
            {...register('day', {
              required: '日は必須です',
              validate: {
          // errorである場合、falseを返す
                dateChecker: () => dateChecker(),
              },
            })}
          >

どの項目からでもバリデーションがトリガーされるようにする

ユーザーがどの項目から入力を始めるかは予測できません。
そのため、すべての項目からバリデーションがトリガーされるように設定し、どこから入力しても正確に検証が実行されるようにする必要があります。

値が再入力されたタイミングでエラーをクリアする

正しい値が入力された場合でも、トリガーとなる項目以外はエラー時のUI状態です。
これではユーザーに混乱を招く可能性があります。
そのため、ユーザーが新たに値を選択するたびに、エラーをクリアします。

  const ERROR_KEYS: (keyof FormData)[] = ['year', 'month', 'day'];
  const clearDateErrors = () => {
    // dateCheckerのエラーである場合、エラーをクリア
    if (ERROR_KEYS.every((key) => errors[key]?.type === 'dateChecker')) {
      clearErrors(ERROR_KEYS);
    }
  };

          <select
            className={`border rounded ${errors.year ? 'border-[#CE0023]' : ''}`}
            {...register('year', {
              required: '年は必須です',
              validate: {
                dateChecker: () => dateChecker() || '存在しない日付です',
              },
              // 値が再選択されたら、エラーをクリア
              onChange: clearDateErrors,
            })}
          >

          <select
            className={`border rounded ${errors.month ? 'border-[#CE0023]' : ''}`}
            {...register('month', {
              required: '月は必須です',
              validate: {
                dateChecker: () => dateChecker(),
              },
             // 値が再選択されたら、エラーをクリア
              onChange: clearDateErrors,
            })}
          >


          <select
            className={`border rounded ${errors.day ? 'border-[#CE0023]' : ''}`}
            {...register('day', {
              required: '日は必須です',
              validate: {
                dateChecker: () => dateChecker(),
              },
              // 値が再選択されたら、エラーをクリア
              onChange: clearDateErrors,
            })}
          >

バリデーションフロー

  1. 個別のバリデーションチェック
    各項目に対して独自のバリデーションが実行されます。

    これは、ユーザーが入力した値が正しいかどうかを確認するための初期ステップです。

  2. すべての項目が正常に選択される
    ユーザーが各項目を正しく選択することで、次のステップへ進むことが可能になります。

  3. dateCheckerのバリデーションチェック
    すべての選択された値が有効な日付として組み合わされるかどうかを確認します。

    このチェックにより、実際に存在する日付かどうかが判断されます。

  4. エラー
    もし選択された値が無効な場合、エラーメッセージが表示され、ユーザーに修正を促します。

  5. 値を再選択
    ユーザーは再度、正しい値を選択する必要があります。

  6. dateCheckerのエラーをクリア
    ユーザーが正しい値を選択した場合、dateCheckerによって発生したエラーがクリアされ、UIが通常の状態に戻ります。

  7. 1に戻る
    すべてのチェックが完了した後、フローは再び「個別のバリデーションチェック」へ戻ります。

終わりに

今回、紹介した方法を活用することで、React Hook Formを使った複数項目の同時バリデーションがよりスムーズに行えるようになります。
ぜひ実際のプロジェクトに応用してみてください!
より良いユーザー体験を提供していきましょう💡

Discussion