😇

React Hook Form で他のフィールドの値を参照した validation を定義する方法とハマったところ

2023/01/17に公開1

React Hook Form (以下、RHF)で他のフィールドの値を参照した validation を定義しようとしてほんのりハマったので、やり方をまとめてみました。
https://github.com/react-hook-form/react-hook-form

前提条件

react-hook-form@7.41.1
react@18.2.0

やりたいこと

以下のような最大値と最小値を入力してもらうフォームにおいて、最大値が最小値より大きくなるように validation を設定したい。

const App = () => {
  const { register } = useForm();

  return (
    <form>
      <input
        type="number"
        {...register('min', { valueAsNumber: true })}
      />
      <input
        type="number"
	{...register('max', { valueAsNumber: true })}
      />
    </form>
  );
};

やり方

  • useFormregistervalidate を定義してカスタムバリデーションを定義
  • カスタムバリデーションの中で useFormgetValues を使って他のフィールドの値を取得

こんな感じ

const App = () => {
  const { register, getValues } = useForm();

  return (
    <form>
      <input
        type="number"
	{...register('min', {
	  valueAsNumber: true,
	  deps: ['max'],
	})}
      />
      <input
        type="number"
	{...register('max', {
	  valueAsNumber: true,
	  validate: (value) => {
	    if (isNaN(value)) { return true } // 未入力時
            return value > (getValues('min') || 0)
	  },
	)}
      />
    </form>
  );
};

ハマったところ

validate で型エラー

RHF の公式ドキュメント に書かれてた以下のサンプルコードにある validateNumber を参考に第2引数に formValues を渡した関数を定義した。

sample_in_docs_v5_v6
// object of callback functions
<input
  {...register("test1", {
    validate: {
      positive: v => parseInt(v) > 0 || 'should be greater than 0',
      lessThanTen: v => parseInt(v) < 10 || 'should be lower than 10',
      validateNumber: (_: number, formValues: FormValues) {
        return formValues.number1 + formValues.number2 === 3 || 'Check sum number';
      },
      // you can do asynchronous validation as well
      checkUrl: async () => await fetch() || 'error message',  // JS only: <p>error message</p> TS only support string
      messages: v => !v && ['test', 'test2']
    }
  })}
/>

しかし、 validate が型エラーになったので、 type Validate<TFieldValue> の定義を確認することに。

ローカルの node_modules/ 内にある型定義ファイルを確認したところ、以下のように引数が1つで定義されていることが発覚して書き方を修正。

src/types/validator.ts
// v7.41.1 (search in node_modules/)
export type ValidateResult = Message | boolean | undefined;
export type Validate<TFieldValue> = (value: TFieldValue) => ValidateResult | Promise<ValidateResult>;

どうやら validate の第2引数は v7.42.0 で実装されたようで、今回使っている v7.41.1 では未実装だったらしい。
https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md#7420---2023-01-13

※ちなみに node_modules/ を見る前に間違えて Github 上で v7.41.1 ではなく v7.42.1 の型定義ファイルをみてしまって混乱していた。錯覚いけないよく見るよろし。

公式ドキュメントのバージョンを誤解

RHF の公式ドキュメント では右上に「V5/V6」という表記があるので、「今表示しているのがV5/V6のドキュメントで、押すとV7に切り替わる」と思って読んでいた。

react-hook-form.com/
V5/V6 のドキュメントだと思って読んでいたが…

しかし、上記の validate の第2引数が v7.42.0 で実装されたことから、「V5/V6」の部分を押すと過去のドキュメントに飛ぶようで、 「V5/V6」と表示されているページは最新の V7 のドキュメント らしい。
(わかりづらい!!)

legacy.react-hook-form.com/
初期状態で古い V7 のドキュメントが表示
「Version 7」の部分をクリックでバージョン選択


「Version 5/6」を選ぶとやっと V5/V6 のドキュメントを表示

上記の挙動については以下の Issue にて there is something to be improved here と書かれており、改善予定の模様
https://github.com/react-hook-form/documentation/issues/845

まとめ

validate を使うときは バージョンごとの差異と参照している公式ドキュメントのバージョンに注意しましょう!

Special Thanks: 黒曜さん

おまけ

valueAsNumber を使うことで未入力時の値が NaN になるため、 validate に渡す関数内で isNaN による判定をしている。

これについて POST や validation を考慮すると、以下の記事を参考に setValueAs を定義して、未入力時は null になるように制御するのが良さそう。(本記事内ではサンプルコードが複雑になるのを避けるために valueAsNumber を使ったパターンを紹介している。)
https://zenn.dev/yodaka/articles/e490a79bccd5e2#registerのvalueasnumberはdefaultvaluesのnullを勝手に0にする(追記:最新版だと直ってるようです)

リーナーテックブログ

Discussion

nap5nap5

superRefineで相関チェックをやってみました。

const SomethingFormSchema = z
  .object({
    min: z
      .custom<Number>()
      .refine(
        (value) => {
          return !isNullOrUndefined(value)
        },
        (value) => {
          return {
            message: '必須入力です',
          }
        }
      )
      .nullable()
      .transform((value) => {
        return Number(value)
      }),
    max: z
      .custom<Number>()
      .refine(
        (value) => {
          return !isNullOrUndefined(value)
        },
        (value) => {
          return {
            message: '必須入力です',
          }
        }
      )
      .nullable()
      .transform((value) => {
        return Number(value)
      }),
  })
  .superRefine(({ min, max }, ctx) => {
    if (min > max) {
      ctx.addIssue({
        path: ['min'],
        code: 'custom',
        message: '"最小値"は"最大値"よりも小さくしてください',
      })
    }
    if (max <= min) {
      ctx.addIssue({
        path: ['max'],
        code: 'custom',
        message: '"最大値"は"最小値"より大きくしてください',
      })
    }
  })

https://codesandbox.io/p/sandbox/magical-chatelet-38p6gb?file=%2FREADME.md&selection=[{"endColumn"%3A1%2C"endLineNumber"%3A3%2C"startColumn"%3A1%2C"startLineNumber"%3A3}]

/min-maxページがデモになります。

簡単ですが、以上です。