👻

React Hook Formハマりどころとベストプラクティス

2022/08/15に公開
4

初めに

React Hook Form(以後、RHF)はとても便利なバリデーションライブラリですが、非制御コンポーネント前提なのでRHFのAPIを通して全ての操作を行うことになります。
そのうえで、自分なりにつまづいたところやわかりづらかったところをまとめました。
個人的に結構このライブラリを扱うのに苦労しているので、皆さんもコメントで自分なりのハマりどころを書いたり、新しく記事にしたりしてネット上に知見が溜まればなと思っています。

ハマりどころ

useFormのdefaultValuesは動的に変更しない

これしっかりドキュメントに書いてあるんですが、defaultValuesを変更したい場合はresetAPIを使いましょう。
https://react-hook-form.com/api/useform

悪い例

このようにAPIからデータが返ってくるまで適当な初期値を渡しちゃうような設計だと、APIからデータが返ってきてもフォームの値が変わらないということがあります。(RHFはdefaultValuesをキャッシュしてしまうため)

// 最初は{ name: '', ... }などの初期値が返り、APIからデータが返ってくるとその値が返る
const { data: profile } = useProfile()

return <Form defaultValues={profile} />

良い例

defaultValuesが確定するまではレンダリングしないようにしましょう。

const { isLoading, data: profile } = useProfile()

if (isLoading) return <Loading />

return <Form defaultValues={profile} />

カスタムエラーのキーは既存のフィールドにネストさせない

たとえばフォームのクロスフィールドバリデーション(パスワードと確認パスワードの値が同じかどうかなど複数のフィールドにまたがるバリデーション)は、RHFではsetErrorを用いてカスタムエラーを定義する必要があります。
しかし、このエラーのキーには一つ制約があります。

悪い例

このようにキーをconfirmPassword.isSamePasswordと既存のフィールドのキーにネストさせてしまうと、handleSubmit時にバリデーションが走る前にconfirmPassword.*配下が全てクリアされます。
なので、一度handleSubmitされたらconfirmPassword.isSamePasswordが勝手にクリアされてしまうのです。
これでは二度ボタンを押せばクロスフィールドバリデーションを回避できてしまいます。


useEffect(() => {
    const valid = validatePassword({ password, confirmPassword })
    if (valid) {
      clearErrors(`confirmPassword.isSamePassword`)
    } else {
      setError(`confirmPassword.isSamePassword`, {
        message: '入力されたパスワードが一致しません。',
      })
    }
  }, [setError, clearErrors, validatePassword, password, confirmPassword])

良い例

なのでこのようにネストしないキー名をつけるようにしましょう。


useEffect(() => {
    const valid = validatePassword({ password, confirmPassword })
    if (valid) {
      clearErrors(`confirmPasswordIsSamePassword`)
    } else {
      setError(`confirmPasswordIsSamePassword`, {
        message: '入力されたパスワードが一致しません。',
      })
    }
  }, [setError, clearErrors, validatePassword, password, confirmPassword])

registerのvalueAsNumberはdefaultValuesのnullを勝手に0にする(追記:最新版だと直ってるようです)

valueAsNumberというのは指定するとnumberにキャストしてくれる便利なオプションなんですが、このvalueAs系の挙動が結構癖があります。
registerのvalueAsNumberはnullを勝手に0にするって「なに言ってんだ当たり前だろ」と思うかもしれないんですが、当たり前じゃないんです。
実はvalueAsNumberのドキュメントにはDoes not transform defaultValue or defaultValues.(defaultValueは変換しない)と書いてあるんですが、defaultValuesだろうとnullは勝手に0に変換されます。
このようにissueが立っていて僕もコメントを残してあるんですが、期待通りの動作らしいです。。
https://github.com/react-hook-form/react-hook-form/issues/6287
この辺がなぜ期待通りの動作なのかわかる人いたら教えてください。

悪い例

<input
  {...register('maxBookableMinutes', {
  valueAsNumber: true,
  })}
/>

良い例

良い例というか回避策です。

<input
  {...register('maxBookableMinutes', {
 setValueAs: value => value == null || value === '' ? null : Number(value),
  })}
/>

追記: value === ''は一瞬でもそのinputに触れるとvalueに空文字が入るのでそれもnullに変換してます。
さらに追記: 最新版だと直ってる(valueはnullのまま返ってくれる)らしいです。
やっぱりバグだったんじゃん!!

registerのsetValueAs、ValueAs系のオプションはradioボタンなどにはつかえない

ドキュメントにもちゃんと書いてあるのですが、これ系のオプションはtype="text"やtype="number"などテキストが入力できるinputに限ります。
なのでtype="radio"とかには使えません。

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

ベストプラクティス

useFormのdefaultValuesは基本的に設定する

RHFではdefaultValuesは必須ではないのだが基本的に設定した方が良いです。
useControllerなど、defaultValuesを設定していないと正しく動かないAPIがあるからです。
もっというと、defaultValueとしてundefinedは指定してはいけません。nullはOKです。(詳しくはドキュメントを読もう!(丸投げ))

...気が向いたら追記します。

終わりに

このライブラリというか、非制御コンポーネントじゃないとフォームのパフォーマンスを担保できないReactのしくみはちょっとどうかなと思っていて(なんか燃えそうな発言)、制御コンポーネントだとRHFは途端に使いづらくなりますし、なかなかReactのフォームは難しい。
非制御になったとたんReactではなくRHFの言葉へと操作が変わるのでシンプルなバリデーションなら全然いんですが、型をしっかり定義して、クロスフィールドバリデーションや動的なフォーム生成など色々複雑なことをやるには大変な印象。
それでもこれ以上簡単に書けて多機能でパフォーマンスが良くてイケてるバリデーションライブラリはないので頑張って使ってます。
まだまだつらみやベストプラクティスが出てきそうなので、もし増えたら追加します。

Discussion

roottoolroottool

追記

間違ったコメントをしていたので、こちらに訂正コメントを記載しています🙏
以下は追記前の内容となっています。

追記前

Does not transform defaultValue or defaultValues.

これに関しては、 「inputタグの defaultValue プロパティ値を上書きしない」という意味合いだと思います。
Issue先のサンプルコードで問題視されている、1つ目のinputに対して defaultValue プロパティ値を追加したサンプルコードで確認できました。
開発者ツールでinputの初期valueプロパティ値が、 numberField の値ではなく defaultValue の値になっていることを確認できます。

よだかよだか

あー!registerのオプションのdefaultValueのことじゃないんですね!
とするとor defaultValues.と書いてあるのはどうなんだろうか。。

roottoolroottool

寝ぼけて間違ったことを記載していたので訂正します!🙇

Issue内での想定通りコメントは、下記コードの意図のことを指しているようです。
https://github.com/react-hook-form/react-hook-form/blob/dd547079dc12b55fa44bd0135a94a8f5d595dfcc/src/logic/getFieldValueAs.ts#L12-L15

nullが0になる要因は、+valueの部分になります。
前回のサンプルコードリンク先でコードを弄って再調査したところ、 +null が0となることが確認出来ました。
ついでで単純な数値(+10)も表示してみましたが、こちらはドキュメント通り改変されないことも確認出来ました。

コードの意図通りではありますが、

Does not transform defaultValue or defaultValues.

"Does not transform" との間に違和感があるのは同感です!
修正するとしてもドキュメントの文章だけで、ロジックの意図とコードの変更には至らなそうな印象ですが😣

よだかよだか

なるほどー
だとしてもnullの場合はisNullOrUndefinedがtrueになるんでNaNになる気がします。。

  : valueAsNumber
    ? value === '' || isNullOrUndefined(value)
      ? NaN
      : +value

現在確認されている挙動はvalueAsNumber: trueの時
1.

<input defaultValue="null" />

NaNが入る

dafaultValues: {
  numberField: null
}

0が入る

って感じですかね
で、共有してくださったgetFieldValueAsはdefaultValuesの時は動かずdefaultValueの時だけ動くようになってるってことかな。。
どちらにしろもうちょい調査してissue立てるなりします!
ありがとうございます!