react-hook-form と zod でバリデーションのその先へ
どうも、 uzimaru です。
最近、react-hook-form と zod を使っていい感じにやっているのでそれについてまとめようと思います。
react-hook-form で zod を使う
公式から利用する方法が提供されています。
https://www.npmjs.com/package/@hookform/resolvers
これを useForm
の resolver
で利用することで 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
です。
useForm
の handleSubmit
を使うのでこの値が返ってくるのですが、このフォームを使う人は「 icon
は FileList
じゃなくて File
がいいな」「 birthday
は、 Date
か Dayjs
がいいな」と思うはずです。
そこで変更します
// ちょっと変えた 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 目的なので、 links
は url
という指定があります。
これを今度は成形します(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