ZodとReact Hook Formを使ったダイナミックなフォームの構築
こんにちは。もう12月ですね。1年ってはやい。
スペースマーケットでエンジニアをしている8zkです。
React Hook Formとは
Reactのフォームを簡単に構築するためのライブラリです。Formikやreact-final-formなどがあります。
類似のライブラリだとこれらのライブラリの中でReact Hook Formを選んだ理由は下記の通りです。
- ダウンロード数が多い
- Star数が多い
- 軽量
ダウンロード数はFormik、react-final-formなどと比べてもダントツで多くなっています。
また、私はフォーム管理のライブラリに詳しくなかったので初心者でも使いやすい・わかりやすいことも理由のひとつです。使い方は下記の通りです。
import { useForm } from 'react-hook-form';
function App() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('firstName')} />
<input {...register('lastName', { required: true })} />
{errors.lastName && <p>Last name is required.</p>}
<input {...register('age', { pattern: /\d+/ })} />
{errors.age && <p>Please enter number for age.</p>}
<input type="submit" />
</form>
);
}
useForm
は主にcomponentにeventをhandleするregister
、formのsubmitに渡す関数をwrapするhandleSubmit
、form内のerrorを検知するerrors
(formState
)を返します。
上記のコードの場合だとユーザーが何も入力せずにsubmitを押下した場合、lastName
とage
にエラーとしてLast name is required.
やPlease enter number for age.
というテキストを表示することができます。
詳しくは弊社 wharaguchiさんのブログがありますので、ぜひ合わせてお読みください!
Zodとは
TypeScript Firstなバリデーションライブラリです。
React Hook Formに対応しているバリデーションライブラリは、Yup、Zod、Joi、Vestなどが挙げられます。
ダウンロード数はZod、Joi、Yup、Vestの順となっています。
Vestはダウンロード数がかなり少ないので、Zod、Joi、Yupの中で選ぶのがよさそうです。
今回Zodを選んだ理由は下記の通りです。
- 他のプロジェクトでZodを使っていて馴染みがあった
- ダウンロード数、Star数ともに1番多かった
それではReact Hook FormとZodを組み合わせたタイプセーフで動的なフォームの作り方を紹介したいと思います。
ダイナミックなフォームの作り方
やりたいこと
- ユーザーが年齢を入力するフォームを作成
- 年齢が18歳未満の場合、「保護者の同意を得ていますか?」というチェックボックスを表示する
- 年齢が18歳未満でチェックボックスにチェックがない場合、「保護者の同意がないと登録できません」というエラーテキストを表示する
イメージはこんな感じです。
結論を先にお伝えすると、コードは下記の通りです。
import { useForm, useWatch, SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
const schema = z
.object({
age: z.number().min(1, { message: '年齢を入力してください' }),
parentalAgreement: z.boolean().optional(),
})
.refine(({ age, parentalAgreement }) => !(age < 18 && !parentalAgreement), {
message: '保護者の同意がないと登録できません',
path: ['parentalAgreement'],
});
type Schema = z.infer<typeof schema>;
export default function Home() {
const {
control,
register,
handleSubmit,
formState: { errors },
} = useForm<Schema>({
resolver: zodResolver(schema),
});
const age = useWatch({ control, name: 'age' });
const onSubmit: SubmitHandler<Schema> = (data) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<p>年齢</p>
<input
type="number"
{...register('age', { valueAsNumber: true })}
/>
</div>
{errors.age && <p color="red">{errors.age.message}</p>}
{age < 18 && (
<label>
<input type="checkbox" {...register('parentalAgreement')} />
保護者の同意を得ていますか?
</label>
)}
{!!age && errors.parentalAgreement && (
<p style={{ color: 'red' }}>
{errors.parentalAgreement.message}
</p>
)}
<button type="submit">
登録
</button>
</form>
);
}
入力された値の変更を検知する
まず、年齢が18歳未満の場合「保護者の同意を得ていますか?」というチェックボックスを表示するにあたり入力された値の変更を検知するuseWatch
を使います。
const age = useWatch({ control, name: 'age' });
とすることでageの変更を検知することができます。
あとはageの値に応じて要素を表示します。
{age < 18 && (
<label>
<input type="checkbox" {...register('parentalAgreement')} />
保護者の同意を得ていますか?
</label>
)}
入力された値に応じてバリデーションをかける
次に、年齢が18歳未満で「保護者の同意を得ていますか?」というチェックボックスにチェックがない場合、「保護者の同意がないと登録できません」というエラーテキストを表示します。
この時に注意すべきはzodのスキーマです。
エラーメッセージを表示する場合、当初このように書きましたがこれは期待した通りに動きません。
const schema = z.object({
age: z.number().min(1, { message: '年齢を入力してください' }),
parentalAgreement: z.boolean().refine((value) => value === true, {
message: '保護者の同意がないと登録できません',
}),
});
18歳以上の場合にエラーとなってしまいます。
これはparentalAgreement
のバリデーションが、age < 18
かどうかの考慮をしていないためです。
なので下記のように書き換えます。
const schema = z
.object({
age: z.number().min(1, { message: '年齢を入力してください' }),
parentalAgreement: z.boolean().optional(),
})
.refine(({ age, parentalAgreement }) => !(age < 18 && !parentalAgreement), {
message: '保護者の同意がないと登録できません',
path: ['parentalAgreement'],
});
age
が18以上の場合、parentalAgreement
のチェックボックスは表示されないためparentalAgreement: z.boolean().optional()
とoptionalとすることも忘れないようにしましょう。
これでage < 18
であることの考慮ができました!
また、さらに複雑なバリデーションをかけたい場合にはrefine
よりもカスタマイズできるsuperRefine
を使います。
先ほどのコードをsuperRefine
で書くとこのようになります。
const schema = z
.object({
age: z.number().min(1, { message: '年齢を入力してください' }),
parentalAgreement: z.boolean().optional(),
})
.superRefine(({ age, parentalAgreement }, ctx) => {
if (age < 18 && !parentalAgreement) {
ctx.addIssue({
path: ['parentalAgreement'],
code: z.ZodIssueCode.custom,
message: '保護者の同意がないと登録できません',
});
}
});
superRefine
を利用した場合、複数の箇所にエラーを表示することもできるので、superRefine
は実質的にrefine
の上位互換のような存在です。
しかし単一のエラーを表示したい場合ではrefine
を使う方がコードは読みやすくなると感じました。
まとめ
簡単ではありますが動的なフォームを作ることができました。
私は業務で最近初めてReact Hook Formを触りましたが、やりたいことは全て表現できることがわかりました。
また、ライブラリを使っている人が多く、つまづいた時には色々な記事で先人たちの知恵をお借りすることができて本当に助けられました。
同じように初めて触る、つまづいた人のお力になれたら幸いです!
さいごに
スペースマーケットでは、一緒にサービスを成長させていく仲間を探しています。
話を聞いてみたい、ちょっとだけ興味がある、などでも大歓迎です!
ご興味ありましたら是非ご連絡ください!
スペースを簡単に貸し借りできるサービス「スペースマーケット」のエンジニアによる公式ブログです。 弊社採用技術スタックはこちら -> whatweuse.dev/company/spacemarket
Discussion