🙅

react-hook-form + zod resolver で useEffect 内での setError が動作しなかった時の対応メモ

2024/08/17に公開

前提・構成情報

  • react 18.2
  • react-hook-form 7.52.5
  • zod 3.23.8
  • @hookform/resolvers 3.9

メモ

<select> 要素でユーザーのロールを選択するようなシナリオがあるとする。

ロールはここでは adminuser の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 にした setTimeoutsetError をラップしてみたところ期待通りにエラーが更新されることが分かった。

  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