React Hook Formハマりどころとベストプラクティス
初めに
React Hook Form(以後、RHF)はとても便利なバリデーションライブラリですが、非制御コンポーネント前提なのでRHFのAPIを通して全ての操作を行うことになります。
そのうえで、自分なりにつまづいたところやわかりづらかったところをまとめました。
個人的に結構このライブラリを扱うのに苦労しているので、皆さんもコメントで自分なりのハマりどころを書いたり、新しく記事にしたりしてネット上に知見が溜まればなと思っています。
ハマりどころ
useFormのdefaultValuesは動的に変更しない
これしっかりドキュメントに書いてあるんですが、defaultValuesを変更したい場合はresetAPIを使いましょう。
悪い例
このように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が立っていて僕もコメントを残してあるんですが、期待通りの動作らしいです。。
この辺がなぜ期待通りの動作なのかわかる人いたら教えてください。
悪い例
<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"とかには使えません。
ベストプラクティス
useFormのdefaultValuesは基本的に設定する
RHFではdefaultValuesは必須ではないのだが基本的に設定した方が良いです。
useControllerなど、defaultValuesを設定していないと正しく動かないAPIがあるからです。
もっというと、defaultValueとしてundefinedは指定してはいけません。nullはOKです。(詳しくはドキュメントを読もう!(丸投げ))
...気が向いたら追記します。
終わりに
このライブラリというか、非制御コンポーネントじゃないとフォームのパフォーマンスを担保できないReactのしくみはちょっとどうかなと思っていて(なんか燃えそうな発言)、制御コンポーネントだとRHFは途端に使いづらくなりますし、なかなかReactのフォームは難しい。
非制御になったとたんReactではなくRHFの言葉へと操作が変わるのでシンプルなバリデーションなら全然いんですが、型をしっかり定義して、クロスフィールドバリデーションや動的なフォーム生成など色々複雑なことをやるには大変な印象。
それでもこれ以上簡単に書けて多機能でパフォーマンスが良くてイケてるバリデーションライブラリはないので頑張って使ってます。
まだまだつらみやベストプラクティスが出てきそうなので、もし増えたら追加します。
Discussion
追記
間違ったコメントをしていたので、こちらに訂正コメントを記載しています🙏
以下は追記前の内容となっています。
追記前
これに関しては、 「inputタグの
defaultValue
プロパティ値を上書きしない」という意味合いだと思います。Issue先のサンプルコードで問題視されている、1つ目のinputに対して
defaultValue
プロパティ値を追加したサンプルコードで確認できました。開発者ツールでinputの初期valueプロパティ値が、
numberField
の値ではなくdefaultValue
の値になっていることを確認できます。あー!registerのオプションのdefaultValueのことじゃないんですね!
とすると
or defaultValues.
と書いてあるのはどうなんだろうか。。寝ぼけて間違ったことを記載していたので訂正します!🙇
Issue内での想定通りコメントは、下記コードの意図のことを指しているようです。
nullが0になる要因は、
+value
の部分になります。前回のサンプルコードリンク先でコードを弄って再調査したところ、
+null
が0となることが確認出来ました。ついでで単純な数値(
+10
)も表示してみましたが、こちらはドキュメント通り改変されないことも確認出来ました。コードの意図通りではありますが、
"Does not transform" との間に違和感があるのは同感です!
修正するとしてもドキュメントの文章だけで、ロジックの意図とコードの変更には至らなそうな印象ですが😣
なるほどー
だとしてもnullの場合はisNullOrUndefinedがtrueになるんでNaNになる気がします。。
現在確認されている挙動はvalueAsNumber: trueの時
1.
NaNが入る
0が入る
って感じですかね
で、共有してくださったgetFieldValueAsはdefaultValuesの時は動かずdefaultValueの時だけ動くようになってるってことかな。。
どちらにしろもうちょい調査してissue立てるなりします!
ありがとうございます!