2022.11.02
MantineとReact Hook FormとZodでフォームを作る

最近ReactやNext.jsでウェブアプリを作るときはMantineとReact Hook FormとZodの組み合わせにハマっています<br> いろんなプロジェクトで似たものを何度か実装していたのでやり方を記録として残します<br>
つくったもの
今回はこれらを使ってサインアップ画面を作ってみました
バリデーションに引っかかるとエラーメッセージが表示されます
Githubリポジトリはこちらです
使う技術
表題の通り、Next.js + Mantine + React Hook From + Zodを使っていきます
Next.js
Next.jsは言わずと知れたReact製のフレームワークです
Mantine
MantineはReactのコンポーネントライブラリです。UIライブラリといえばMUIやChakra UIが有名ですが、MUIはどうがんばっても見た目がGoogleっぽくなってしまうし、Chakra UIはたまにコンポーネントが足りなくて絶妙な使いづらさがあります(特にDatePickerが痛いです)<br> その点、Mantineはコンポーネント数が多く、見た目もかわいいです。DatePickerやMultiSelectはもちろん、DropzoneやSpotlightなどのニッチなコンポーネントまであります。Hooksも豊富で使いやすそうです(まだそこまで使いこなせていない)<br> 例えばuse-disclosureは
function Demo() {
const [opened, handlers] = useDisclosure(false);
// Sets opened to true
handlers.open();
// Sets opened to false
handlers.close();
// Sets opened to true if it was false and vice versa
handlers.toggle();
}
というように使えます。 handlers
にすべてまとまっているのが使いやすいです
React Hook Form
React Hook Formはフォームバリデーションライブラリで、state管理などのコードの記述量を減らすことができます。従来だと入力値ごとにuseStateで変数を用意してあげる必要があったところを、register
を使って各入力フォームの要素の参照を登録できます。こちらもみなさん馴染み深いと思うのでそこまで語ることはないでしょう
Zod
ZodはTypeScript Firstなバリデーションライブラリで、"Functional approach: parse, don't validate" という思想があります。React Hook Formのregisterにもバリデーションを書くことはできますが、Zodを使うとバリデーションの宣言を一箇所にまとめることができます
実装
それではつくっていきます<br> まずはベースとなるNext.jsのテンプレートは自作のものを使いました
準備
必要なものをインストールします
$ yarn add @mantine/core @mantine/dates @mantine/form @mantine/hooks @mantine/next @emotion/server @emotion/react dayjs
$ yarn add react-hook-form @hookform/resolvers zod
フォームの見た目
まずはMantineでフォームの見た目を作ります<br> 入力項目としてはユーザー名、生年月日、性別、メールアドレス、パスワード、利用規約への同意の6項目を用意しました<br> MantineにはDatePickerやPasswordInputのコンポーネントもあって便利でした
<form onSubmit={() => {}}>
<Stack>
<Stack spacing="md">
<TextInput label="ユーザー名" placeholder="" />
<DatePicker label="生年月日" />
<Select
label="性別"
placeholder="ひとつ選んでください"
data={[
{ value: 'female', label: '女性' },
{ value: 'male', label: '男性' },
{ value: 'unknown', label: 'その他' }
]}
/>
<TextInput label="メールアドレス" placeholder="email@example.com" />
<PasswordInput label="パスワード" />
<Checkbox label="利用規約に同意しました" />
</Stack>
<Text size="xs">
<Link href="#">パスワードを忘れた場合はこちら</Link>
</Text>
<Button type="submit">ログイン</Button>
</Stack>
</form>
フォームの入力値を取得する
次にReact Hook Formでフォームに入力された値を取得できるようにします<br>
まずはuseForm
でフォームの準備をします<br>
// ロジック側
const {
register,
handleSubmit,
formState: { isSubmitting }
} = useForm()
const [birthday, setBirthday] = useState<Date | null>(new Date())
const [gender, setGender] = useState<string | null>()
const [checked, setChecked] = useState<boolean>(false)
const onSubmit = (data: any) => {
console.log('submit', {
...data,
birthday,
gender
})
}
注意点として、MantineのDatePickerとSelectはrhfのregisterを直接使えないので useState
で愚直に変数を用意してあげる必要があります<br>
続いてhtml側にrhfのregisterを差し込んでいきます
// 見た目側
<form onSubmit={handleSubmit(onSubmit)}>
<Stack>
<Stack spacing="md">
<TextInput label="ユーザー名" {...register('username')} />
<DatePicker
label="生年月日"
placeholder="日付を選択してください"
value={birthday}
onChange={setBirthday}
/>
<Select
label="性別"
placeholder="ひとつ選んでください"
data={[
{ value: 'female', label: '女性' },
{ value: 'male', label: '男性' },
{ value: 'unknown', label: 'その他' }
]}
value={gender}
onChange={setGender}
/>
<TextInput
label="メールアドレス"
placeholder="email@example.com"
{...register('email')}
/>
<PasswordInput label="パスワード" {...register('password')} />
<Checkbox label="利用規約に同意しました" />
</Stack>
<Text size="xs">
<Link href="#">パスワードを忘れた場合はこちら</Link>
</Text>
<Button type="submit" loading={isSubmitting}>
ログイン
</Button>
</Stack>
</form>
バリデーションを追加する
最後にZodでバリデーションを書き加えていきます<br> Zodでフォームの型を記述します
const emailPattern = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i
const SignUpSchema = z.object({
username: z.string().min(1, { message: 'ユーザー名を入力してください' }),
email: z
.string()
.regex(emailPattern, { message: '無効なメールアドレスです' }),
password: z
.string()
.min(8, { message: 'パスワードは8文字以上にしてください' }),
})
type SignUpInputType = z.infer<typeof SignUpSchema>
この型を使ってフォームにタイプアノテーションを書いていきます
const {
register,
handleSubmit,
formState: { isSubmitting }
} = useForm<SignUpInputType>({ resolver: zodResolver(SignUpSchema) })
const [birthday, setBirthday] = useState<Date | null>(new Date())
const [gender, setGender] = useState<string | null>()
const [checked, setChecked] = useState<boolean>(false)
const onSubmit: SubmitHandler<SignUpInputType> = (data) => {
console.log('submit', {
...data,
birthday,
gender
})
}
最後にhtml側でバリデーション結果のエラーメッセージを表示するように修正します<br> メールアドレスは正規表現でバリデートしたり、利用規約に同意していないと登録ボタンが押せないようにしたりしています
<form onSubmit={handleSubmit(onSubmit)}>
<Stack>
<Stack spacing="md">
<TextInput
label="ユーザー名"
required
error={errors.username?.message}
{...register('username')}
/>
<DatePicker
label="生年月日"
placeholder="日付を選択してください"
required
value={birthday}
onChange={setBirthday}
/>
<Select
label="性別"
placeholder="ひとつ選んでください"
data={[
{ value: 'female', label: '女性' },
{ value: 'male', label: '男性' },
{ value: 'unknown', label: 'その他' }
]}
required
value={gender}
onChange={setGender}
/>
<TextInput
label="メールアドレス"
placeholder="email@example.com"
required
error={errors.email?.message}
{...register('email')}
/>
<PasswordInput
label="パスワード"
required
error={errors.password?.message}
{...register('password')}
/>
<Checkbox
label="利用規約に同意しました"
required
checked={checked}
onChange={(event) => setChecked(event.currentTarget.checked)}
/>
</Stack>
<Text size="xs">
<Link href="#">パスワードを忘れた場合はこちら</Link>
</Text>
<Button type="submit" disabled={!checked} loading={isSubmitting}>
登録する
</Button>
</Stack>
</form>
<br> 最終的にはこのようになります<br> これで完成です🎉
さいごに
今アツいライブラリ3つを使ってフォームを作ってみました<br> Mantineには独自のフォーム用のhookが用意されており、こちらでもZodを組み合わせることはできます<br> 将来的にはDatePickerやSelectのときもRHFのregisterを渡せるようになんとかしたいです
2023.05.14 追記
react-hook-formのControllerを使ってonChangeの型が違うコンポーネントもregsiterできるようにしました<br>
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<SignUpInputType>({
resolver: zodResolver(SignUpSchema),
mode: 'all',
})
useFormから渡ってくるcontrolをコンポーネントに渡してあげることで任意のフィールドのvalueとonChangeを使うことができます
<Controller
name="gender"
control={control}
render={({ field }) => (
<NativeSelect
label="性別"
placeholder="ひとつ選んでください"
data={[
{ value: 'female', label: '女性' },
{ value: 'male', label: '男性' },
{ value: 'unknown', label: 'その他' },
]}
required
value={field.value}
onChange={field.onChange}
/>
)}
/>