最近、react-hook-form と zod を使っていい感じにやっているのでそれについてまとめようと思います。
react-hook-form で zod を使う
公式から利用する方法が提供されています。
これを useForm の resolver で利用することで zod が使えるようになります。
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 を使っている人はぜひやってみてください!