📝

react-hook-form と zod でバリデーションのその先へ

2022/06/23に公開

どうも、 uzimaru です。
最近、react-hook-form と zod を使っていい感じにやっているのでそれについてまとめようと思います。

react-hook-form で zod を使う

公式から利用する方法が提供されています。
https://www.npmjs.com/package/@hookform/resolvers
これを useFormresolver で利用することで zod が使えるようになります。
zod 以外にも Yup, Superstruct, Joi, io-ts などが利用できます

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

const schema = z.object({
  name: z.string().min(1, { message: "Required" }),
  age: z.number().min(10),
});

const App = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(schema),
  });

  return (
    <form onSubmit={handleSubmit(d => console.log(d))}>
      <input {...register("name")} />
      {errors.name?.message && <p>{errors.name?.message}</p>}
      <input type="number" {...register("age", { valueAsNumber: true })} />
      {errors.age?.message && <p>{errors.age?.message}</p>}
      <input type="submit" />
    </form>
  );
};

ここあたりは結構記事になっているのでそこまで詳しく説明しません。
実際、ここまでの内容でもフォームをスキーマからバリデーションできるのでとても便利です。

Form が真にやりたいこと

ここからが本題です。
Form が真にやりたいことは何でしょうか?
Form だと主語がデカイので Form を提供するコンポーネントが真にやりたいことにします。
僕は ユーザーからの入力を得て都合のいい形にデータを成形して返却する ということだと思います。
Form コンポーネントの Props を見て考えていきましょう

例 <ユーザーの設定画面>

サービスのユーザー情報を設定するフォームを例に考えてみます。

// コード内で使われる型
interface User {
  icon: string
  name: string
  birthday: Dayjs
  profile: string
  links: string[]
}

// 愚直に書いた Form の Props
interface FormProps {
  icon?: string
  name?: string
  birthday?: Dayjs
  profile?: string
  links?: string[]
  // 多分通信するので非同期関数がよさそう
  // icon は `type=file` の input を使うので `FileList` になるはず
  onSubmit({
    icon: FileList,
    name: string,
    // フォームが年・月・日を別々に入力する形になってる
    birthday: {
      year: number,
      month: number,
      day: number
    },
    profile: string,
    links: string[]
  }): void
}

前項で書いたコード例のような感じでコンポーネントを作ろうとすると、こんな感じになると思います。
ここで注目したいのは onSubmit です。
useFormhandleSubmit を使うのでこの値が返ってくるのですが、このフォームを使う人は「 iconFileList じゃなくて File がいいな」「 birthday は、 DateDayjs がいいな」と思うはずです。
そこで変更します

// ちょっと変えた Form の Props
interface FormProps {
  icon?: string
  name?: string
  birthday?: Dayjs
  profile?: string
  links?: string[]
  // icon は `type=file` の input を使うので `FileList` になるはず
  onSubmit({
    // icon 未設定もありえる
    icon: File | null,
    name: string,
    birthday: Dayjs,
    profile: string,
    links: string[]
  }): void
}

// 実装はこうする
...
<form onSubmit={handleSubmit(x => {
  const icon = x.icon?.item(0)
  const birthday = dayjs(`${x.birthday.year}-${x.birthday.month}-${x.birthday.day}`)

  onSubmit({
    ...x,
    icon,
    birthday
  })
})}>
  ...
</form>

handleSubmit に渡す関数内で onSubmit が受け取る型に変換する形になります。
ここが先に話していた都合のいい形にデータを成形して返却するという部分です。
プログラミングの関心は大きく分けると 入力出力 に分かれると思うのですが、これは Form にも適応できると思います。
データを成形することがない場合(最初のコード例)だと 入力 にしか関心がなく 出力 には特に関心が無いことになります。
察しがいい人はもう分かったと思うのですがこのデータの成形を zod にやらせちゃおうという感じです。

zod でデータ成形

先程のコード例を使って zod の schema を書いてみます

const schema = z.object({
  icon: z.nullable(z.instanceOf(FileList)),
  name: z.string(),
  birthday: z.object({
    year: z.number(),
    month: z.number(),
    day: z.number(),
  }),
  profile: z.string(),
  links: z.array(z.string().url()),
});

これがフォームの schema になります。validation 目的なので、 linksurl という指定があります。
これを今度は成形します(2 つ目のコード例の onSubmit の形)

const schema = z
  .object({
    icon: z.nullable(z.instanceOf(FileList)),
    name: z.string(),
    birthday: z.object({
      year: z.number(),
      month: z.number(),
      day: z.number(),
    }),
    profile: z.string(),
    links: z.array(z.string().url()),
  })
  .transform(x => {
    const icon = x.icon?.item(0);
    const birthday = dayjs(
      `${x.birthday.year}-${x.birthday.month}-${x.birthday.day}`
    );

    return {
      ...x,
      icon,
      birthday,
    };
  });

やってることは handleSubmit の中でやっていた変換処理と同じですね。
schema をこうすることで handleSubmit はこのように書けます。

...
<form onSubmit={handleSubmit(onSubmit)}>
  ...
</form>

とてもスッキリしました!
transform はとても自由なので、以下のようなことも可能です

// onSubmit が FormData を受け取りたい
...
}).transform(x => {
  const formData = new FormData()

  const icon = x.icon?.item(0)
  if (icon) {
    formData.append('icon', icon)
  }
  const birthday = dayjs(`${x.birthday.year}-${x.birthday.month}-${x.birthday.day}`)
  formData.append('birthday', birthday.unix())
  // 残りを FormData に詰め込む
  ...
})

まとめ

zod の利用方法が validate のみに限られがちですが、任意のデータ構造へのマッピングにも利用できます。
これを Form にも利用することでシンプルに onSubmit の型に適応できるので react-hook-form を使っている人はぜひやってみてください!

Discussion