📚

React Hook Form + Zod で refine()を利用した非同期検証

2024/09/03に公開

フロントエンド専門の制作会社「株式会社トゥーアール 」で代表をしている @KazumaNishihata です。

今回はReact Hook Formのrefineを利用した非同期検証を作成したところ色々とハマったので書いておきます。

https://react-hook-form.com/

refineとは?

React Hook Form + Zodではrefineを利用独自のバリデーションを作成することができます。

Zodのスキーマを以下のように定義すると「abc」以外の値をエラーとするバリデーションを作成できます。

z.string().refine((val) => val === 'abc',{
  message: 'テキストは「abc」と入力してください',
})

React Hook Form + Zod で作成されたフォームで利用時のサンプルは以下のようになります。

"use client"
import { useForm } from "react-hook-form"
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

export const formSchema = z.object({
  text: z.string().refine((val) => val === 'abc',{
    message: 'テキストは「abc」と入力してください',
  })
})

type FormSchemaType = z.infer<typeof formSchema>;

export function FormField() {

  const { register, handleSubmit, formState: {errors} } = useForm<FormSchemaType>({
    resolver: zodResolver(formSchema),
    mode: 'onChange',
  });

  return (
    <div>
      <form onSubmit={handleSubmit(() => {})}>
        <input type="text" {...register('text')} />
        <p>{errors.text && errors.text.message}</p>
        <button>送信する</button>
      </form>
    </div>
  );
}

https://zod.dev/?id=refine

非同期検証とは

メールアドレスの存在チェックといったローカル環境だけでは確認できずAPIリクエストを投げないと検証ができない内容のことを非同期検証といいます。

例えば以下のような textが「abc」の場合のみ status:true を返すAPIを用意しておき、

/api/route.ts
export async function POST(req: Request) {
  const { text } = await req.json()
  return Response.json({
    status: text === 'abc'
  })
}

Zodのスキーマを以下のように変更します。

export const formSchema = z.object({
  text: z.string().refine(async(text) => {
    const res = await fetch(`/api`, {
      method: 'POST',
      body: JSON.stringify({ text }),
      cache: 'no-store' // Nextの場合はキャッシュしないように
    })
    const { status}  = await res.json()
    return status
  },{
    message: 'テキストは「abc」と入力してください',
  })
})

そうするとAPIを利用した非同期検証が可能になります。

問題: refineの発火タイミング

これでイケると思ったのですがrefineの発火タイミングに落とし穴がありました。

上記のサンプルにもう一つテキストフィールドを追加します。

"use client"
import { useForm } from "react-hook-form"
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

export const formSchema = z.object({
  text: z.string().refine(async(text) => {
    const res = await fetch(`/api`, {
      method: 'POST',
      body: JSON.stringify({ text }),
      cache: 'no-store'
    })
    const { status}  = await res.json()
    return status
  },{
    message: 'テキストは「abc」と入力してください',
  }),
+ text2: z.string()
})

type FormSchemaType = z.infer<typeof formSchema>;

export function FormField() {

  const { register, handleSubmit, formState: {errors} } = useForm<FormSchemaType>({
    resolver: zodResolver(formSchema),
    mode: 'onChange',
  });

  return (
    <div>
      <form onSubmit={handleSubmit(() => {})}>
        <input type="text" {...register('text')} />
        <p>{errors.text && errors.text.message}</p>
+       <input type="text" {...register('text2')} />
+       <p>{errors.text2 && errors.text2.message}</p>
        <button>送信する</button>
      </form>
    </div>
  );
}

こうするとtext2フィールドになにか入力された場合にも非同期検証が走ってしまいます。

refineは相関チェックを行うため同一のz.object()内の他のフィールドにイベントが発生した場合も発火してしまうためです。

対応方法

他フィールドでの更新の際に発火させないという対策ができればよかったのですが、その方法はわからず以下の対応方法を参考にしました。

https://tech.smartcamp.co.jp/entry/react-hook-form-zod-recoil-vol2

これはAPIのリクエスト結果を保存しておき同一のテキストの場合は再リクエストを発火させないという方法です。

"use client"
import { useForm } from "react-hook-form"
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

+ const existsValue = new Map<string, boolean>()

export const formSchema = z.object({
  text: z.string().refine(async(text) => {
+   if (existsValue.has(text)) {
+     return existsValue.get(text)
+   }
    const res = await fetch(`/api`, {
      method: 'POST',
      body: JSON.stringify({ text }),
      cache: 'no-store'
    })
+   const { status}  = await res.json()
    existsValue.set(text, status)
    return status
  },{
    message: 'テキストは「abc」と入力してください',
  }),
  text2: z.string()
})

type FormSchemaType = z.infer<typeof formSchema>;

export function FormField() {

  const { register, handleSubmit, formState: {errors} } = useForm<FormSchemaType>({
    resolver: zodResolver(formSchema),
    mode: 'onChange',
  });

  return (
    <div>
      <form onSubmit={handleSubmit(() => {})}>
        <input type="text" {...register('text')} />
        <p>{errors.text && errors.text.message}</p>
        <input type="text" {...register('text2')} />
        <p>{errors.text2 && errors.text2.message}</p>
        <button>送信する</button>
      </form>
    </div>
  );
}

これで、他のフィールドの更新でAPIリクエストが発生することがなくなりました。

ちょっと無理やりな対応な気がしますのでもっと良い方法をご存じの方いましたら教えていただけたらと。

株式会社トゥーアール

Discussion