react-hook-form + zod resolver で useEffect 内での setError が動作しなかった時の対応メモ
前提・構成情報
- react 18.2
- react-hook-form 7.52.5
- zod 3.23.8
- @hookform/resolvers 3.9
メモ
<select> 要素でユーザーのロールを選択するようなシナリオがあるとする。
ロールはここでは admin と user の2値を選択出来るとする。
zod で以下のようなスキーマを定義する。
const FormSchema = z.object({
role: z.enum(["admin", "user"]),
})
useForm の resolver プロパティにこのスキーマを与える。
const {
handleSubmit,
watch,
setError,
control,
formState: { errors },
} = useForm({
resolver: zodResolver(FormSchema),
defaultValues: {
role: "admin",
},
mode: "onChange",
})
この admin ロールのユーザーには作成上限が存在しており、その上限に達している場合にはエラーを表示させたい、といったユースケースがある。
また、このフォームを持つコンポーネントは作成上限の値を props から受け取る。
useForm の戻り値から取得した watch 関数を呼び出して role フィールドの変更を監視する。
以下のような useEffect を記述する。
useEffect(() => {
if (role === "admin" && limit > 5) {
setError("role", {
type: "manual",
message: "admin is not allowed to register",
})
}
}, [role])
admin が選択されて、且つ limit の条件を満たす場合 setError が呼び出されることにより errors オブジェクトが更新され、それを参照したのエラーメッセージが表示されることを期待していた。
しかし、この setError は期待通りに動作しなかった。
色々と試行錯誤をした結果、setError の実行後にどこかのタイミングで zod のバリデーションが実行されているのではないかと思い至った。(入力されている admin という値は zod スキーマを満たすためエラーにならず、react-hook-form ではこのような場合 errors を更新する)
仮に zod バリデーションが動作しているのだとして、そのバリデーションよりも後に setError が呼び出されればよいのかと考え、以下のように待機時間を 0 にした setTimeout で setError をラップしてみたところ期待通りにエラーが更新されることが分かった。
useEffect(() => {
if (role === "admin" && limit > 5) {
setTimeout(() => {
setError("role", {
type: "manual",
message: "admin is not allowed to register",
})
}, 0)
}
}, [role])
useForm の呼び出し時に resolver を与えないようにするとこの現象は発生しなかった。(setTimeout を使わずに setError だけでエラーの状態が更新された)
useEffect ではなく onChange メソッドに割り込んで setError を呼び出す場合でも同様の現象が発生した。
react-hook-form のリポジトリを見てみると resolver から与えたスキーマの実行は非同期処理になっており、これが期待とは異なる動作の原因になっているのではと考えたが、ソースコードの内容はほとんど読み解くことが出来なかった。
これを書いてから思いついた
こんな風にしてみたら良かったのかもしれない?
const formSchema = useMemo(() => {
return z.object({
role: z.string().refine(
(value) => !(limit && value === 'admin'),
{
message: "Admin selection is not allowed when limit is true",
}
),
});
}, [limit]);
Discussion