0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

useFormでバリデーション制御に悩んだ話 〜setErrorとisValid〜

Posted at

TL;DR

  • setErrorするだけではisValidが変わらない
  • 素直にregisterやControllerのonChangeを使おう

はじめに

React Hook Form(以下 RHF)は、軽量で直感的に使えるフォームライブラリとして、多くのReactプロジェクトで利用されています。実際に使ってみると、register や Controller を通して簡単にバリデーションを設定でき、フォームの状態も formState を通じて一括管理できるので非常に便利です。

しかし、実際の開発では「ちょっとした例外的なケース」にぶつかることがあります。今回私が遭遇したのはその一つ。
setError を使って手動でエラーを出したのに、isValid がなぜか true のままになってしまう、という現象です。

バリデーションエラーが表示されているのに、送信ボタンが押せてしまう…
フォームの見た目と状態が一致しないもどかしさに、思わずコードとにらめっこしてしまいました。

この記事では、この現象を再現したシンプルなコードと、調査・検証の過程を共有します。React Hook Form を使っていて、「あれ?なんでこれ通っちゃうの?」と感じた方のヒントになれば嬉しいです。

今回のケース

改めて、今回遭遇してケースをシンプルにした形で整理します。

  • RHFを使ってバリデーション付きのフォーム機能を実装
  • Inputが1つあり、文字数の制限、必須項目としてバリデーションを設定し、エラーを表示したい
  • 送信ボタンはエラーがある場合は押せないようにしたい

ざっくりこんな感じです。よくあるパターンかと思います。
そして自分が最初に書いていたコードが以下になります。(Chakra UIを使用しています)

import React from 'react'
import { Box, Button, Input } from '@chakra-ui/react'
import { useForm } from 'react-hook-form'

type FormData = {
  name: string
}

export const Form4 = () => {
  const {
    setValue,
    getValues,
    handleSubmit,
    setError,
    clearErrors,
    formState: { errors, isValid }
  } = useForm<FormData>({
    mode: 'all',
    defaultValues: { name: '' }
  })

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value

    if (value.length > 5) {
      setError('name', { message: '5文字以内で入力してください。' })
    } else if (value.length === 0) {
      setError('name', { message: '必須項目です。' })
    } else {
      clearErrors('name')
    }
    setValue('name', value)
  }

  const onSubmit = (data: FormData) => {}

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Input
        w="50%"
        name="name"
        value={getValues('name')}
        placeholder="5文字以内"
        onChange={handleChange}
      />
      <Box mt={2}>
        {errors.name && <p style={{ color: 'red' }}>{errors.name.message}</p>}
      </Box>
      <Button type="submit" bg="blue.500" color="white" disabled={!isValid}>
        Submit
      </Button>
    </form>
  )
}
  • useFormをhookを使ってsetValuegetValuessetErrorclearErrorsをしていて、状態を管理しています。
  • formStateプロパティからisValidを取得して、送信ボタンのdisabled属性を制御している。
  • formStateプロパティからerrorsを取得して、エラーがある場合はエラーメッセージを表示している。

バリデーションロジックや複数項目など実際はもう少し複雑かと思いますが、シンプルにすると大体こんな感じかなと思います。
コードだけ見ると一見正常に動きそうですが、実際には一度エラーになるとisValidがfalseのまま切り変わらず送信ボタンが押せなくなってしまいます。

rhf-1.gif

なぜ...?と思い、公式ドキュメントを見ていたところ原因がわかりました。
isValidはフォーム全体の状態を判定してtrue/falseを返します。
しかし、clearErrorsではisValidには影響を与えません。

This will not affect the validation rules attached to each inputs.

そして、setErrorでは強制的にisValidをfalseにします。

This method will force set isValid formState to false. However, it's important to be aware that isValid will always be derived from the validation result of your input registration rules or schema result.

つまり、この場合だと、

  1. カスタムのバリデーションに引っかかった場合はsetErrorしてisValidを強制的にfalseに切り替える
  2. InputのonChangeでバリデーションに引っかからない正常な値になった時にclearErrorして、errorsオブジェクトを空にする。
  3. エラー表示は無くなるが、clearErrorはフォームに対してsubscribeしているわけではないので、フォーム全体の状態を監視して判定するisValidは切り替わらない
  4. ボタンは非活性のままになる

ということになります。

どうやって解決するか

ではどうすればよかったのか。色々試しました。結論としては、register()を使ってフォーム値として登録すれば良いのですが、他にも方法があるのではと思い検証してみました。

1. setValueするときにshouldValidateオプションを使ってみる

まず試してみたことがこれです。

Whether to compute if your input is valid or not (subscribed to errors).
Whether to compute if your entire form is valid or not (subscribed to isValid).

一度エラーになった後isValidがfalseのままなのが問題なら、その後正常な値をsetValueする時にshouldValidate: trueオプションを使えば再評価されて切り替わるんじゃないか?と思いました。

if (value.length > 5) {
      setError('name', { message: '5文字以内で入力してください。' })
    } else if (value.length === 0) {
      setError('name', { message: '必須項目です。' })
    } else {
      clearErrors('name')
    }
    setValue('name', value, { shouldValidate: true })
    // setValue('name', value)

しかし、実際の挙動は以下でした。

rhf-2.gif

はい。ボタンがずっと活性状態です。コンソールを見るとわかるのですが、setErrorでは確かにisValidがfalseになっています。が、その後すぐにtrueに切り替わっています。そしてerrorsオブジェクトは期待通り更新されたままです。

これも結局、setErrorしただけでは、フォームに対して値を登録しているわけではないので、isValidに影響がないという仕様が関係しています。ドキュメントよるとsubscribed to isValidで、shouldValidate: trueで確かにフォーム対して登録しているのですが、現状のコードではバリエーション自体はuseFormの管理下にありません。onChangeでif-else文で独自で書いた分岐処理をバリデーションとしているだけであって、それがusFormに登録されているわけではないのです。なので、onChangeで最後にsetValueする際にどんな値でもOKという判定になり、isValidは必ずtrueに切り替わるということです。これではダメですね...

2. trigger()を挟んで再評価する

ここまで読んでいただいたらもうお分かりかと思うのですが、これも1.のshouldValidate: trueと同様の動きになります。なぜならカスタムのバリデーションがuseFormの管理下にあらず、内部的にはバリデーションなしのどんな値でもOKな状態なので、trigger()でフォームの値を再評価してもisValidは必ずtrueに切り替わるだけです。なのでこれもダメでした...

3. registerを使う

次はregister()使ってフォームに値を登録する方法です。
register を使うことで、React Hook Form に対して input 要素とバリデーションルールの紐付けが明示的に行われます。そのため、値が変化した際に自動でバリデーションが実行され、isValid が正しく反映されます。
registerメソッドには第二引数でオプションが指定できます。下記のようにバリデーションを登録しました。

<Input
  w="50%"
  placeholder="5文字以内"
  type="text"
  {...register('name', {
    required: '必須項目です',
    maxLength: {
      value: 5,
      message: '5文字以内で入力してください'
    }
  })}
/>

rhf-3.gif

これはうまく動いてそうです。registerを使ったからというより、バリデーションをuseFormの監視下に登録したということですね。

4. Controllerのrulesでバリデーションを実行

最後にRHFのControllerコンポーネントを使う方法です。

<Controller
  control={control}
  name="name"
  rules={{
    maxLength: { value: 5, message: '5文字以内で入力してください' },
    required: { value: true, message: '必須項目です' }
  }}
  render={({ field: { value, onChange }, fieldState: { error } }) => (
    <>
      <Input
        value={value ?? ''}
        w="50%"
        placeholder="5文字以内"
        type="text"
        onChange={onChange}
      />
      <Box mt={2}>
        {error && <p style={{ color: 'red' }}>{error.message}</p>}
      </Box>
    </>
  )}
/>

これは、<Controller></Controller>で囲い、useFormからcontrolプロパティを受け取りそれをそのままControllerのcontrol propsに渡してあげます。(これで監視下にするみたいなイメージです)

そして、render()の中で該当のInputをレンダリングします。Controllerにはrulespropsが用意されており、ここにバリデーションを設定することができます。

この場合は、render()の引数からfield.valuefieldState.errorを取得できるので、そのまま値をセットできます。onChangeも独自で関数を定義して、registerやsetValueできますが、これもfield.onChangeを取得できるのでそのままInputのonChangeイベントに渡してあげると楽に実装できます。

ただし、一点だけ注意する部分あり、field.onChangeを使う場合はuseFormのmodeonChange or onBlur or all等に変更してください。デフォルトがonSubmitになっており、送信ボタンを押さないとerrorsオブジェクトに値が入ってこないので調整が必要です

const {
  handleSubmit,
  getValues,
  control,
  formState: { isValid }
} = useForm<FormData>({
  mode: 'onChange',
  defaultValues: {
    name: 'テスト',
  }
})

じゃあsetErrorっていつ使うの?

ここまでで、isValidとsetError、clearErrorについてみてきましたが、「isValidに影響を与えないのであればclearErrorやsetErrorってなんのためにあるの?」と疑問に思いました。
そこで、もう少しドキュメントを読み進めていると、

Can be useful in the handleSubmit method when you want to give error feedback to a user after async validation. (ex: API returns validation errors)
とありました。

1つの使用例としてクライアント側のバリデーションではなくsubmitをしてAPIからエラーが返ってきた時にsetErrorしてerrorを表示したりできるよってことですね。コードで書くと以下のような感じでしょうか。

const fakeApiResponse = {
  success: false,
  errors: {
    name: 'このnameは既に存在します'
  }
}

const onSubmit = (data: FormData) => {
  console.log('送信データ:', data)

  console.log(data);
  if (!fakeApiResponse.success) {
    Object.entries(fakeApiResponse.errors).forEach(([field, message]) => {
      setError(field as keyof FormData, {
        type: 'server',
        message
      })
    })
    return
  }

  alert('送信成功')
}
<form onSubmit={handleSubmit(onSubmit)}>
  <Input
    w="50%"
    placeholder="5文字以内"
    type="text"
    {...register('name', {
      required: '必須項目です',
      maxLength: {
        value: 5,
        message: '5文字以内で入力してください'
      }
    })}
  />
  <Box mt={2} mb={2}>
    {errors.name && <p style={{ color: 'red' }}>{errors.name.message}</p>}
  </Box>

  <Button type="submit" bg="blue.500" color="white" disabled={!isValid}>
    Submit
  </Button>
</form>

rhf-4.gif

確かにuseFormでerrorの状態を管理する場合、useFormのerrorsに追加しないと2重管理になってしまいますね...。だからsetErrorしてuseFormのerrorに直接登録するということですね!

(ちなみに入力するとバリデーションが再評価されるのでclearErrorは必要ありません。)

まとめ

今回はuseFormのisValidとsetErrorの関係についてみてきました。
普通はregisterを使うことがほぼだと思うので、あまり気にすることはないと思うのですが、サーバー側のエラーをsetErrorするようにしたり、shouldValidateオプションだったり、知らなかったことも多かったので良い勉強になりました。
useFormのバリデーション周りで困っている方のヒントになれば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?