MUI + React Hook Form + Zod でフォーム作成
はじめに
今回作るMUI + React Hook Form + Zodのソースコードは↓こちらです。
開発環境
"vite": "^5.1.0"
"react": "18.2.0"
"typescript": "5.2.2"
"react-hook-form": "7.49.2"
"@mui/material": "5.15.0"
"@mui/x-date-pickers": "6.18.6"
Zodとは
TypeScript ファーストのスキーマ宣言および検証ライブラリです。私は、単純なオブジェクトから複雑なネストされたオブジェクトまで、あらゆるデータ型を広く指すために「スキーマ」という用語を使用しています
なぜReact Hook FormとZodを組み合わせるのか?
React Hook Formにもvalidate apiが存在し、バリデーションロジックを書くことは可能だが、他のフォームの値を参照するようなロジックを実装しようとすると、コンポーネント側に書く必要が出てきて、コードの分離が難しくなる。
つまり、ロジックの使い回しができなかったりテストも書きにくくなったりする。そのためzodのようなバリデーションライブラリを用いてロジックの分離を行う。
MUI と React Hook Form と Zod でフォーム作成する
- Zodでスキーマの定義
- フォームの型定義
- defaultValueを作成する関数を作成する
- RHFにZodスキーマを適用
- 各フォームコンポーネント作成
- 手順5 で作成したコンポーネントでフォーム作成
※RHF:React Hook Formのこと
Zodでスキーマの定義
const schema = z.object({
nullAbleText: z.string(),
text: z.string().min(1, {message: 'テキストを入力してください。'}),
// TextField の value が string 扱いなため、string として定義
nullAbleNumber: z.string(),
number: z.string().refine(val => val !== '', {message: "数値を入力してください。"}).refine(val => Number(val) >= 0, {message: "有効な数値を入力してください。"}),
nullAbleSelect: z.string(),
select: z.string().min(1, {message: '選択してください。'}),
nullAbleCheckboxes: z.array(z.string()),
checkboxes: z.array(z.string()).refine(vals => vals.length > 0, {message: '少なくとも1つは選択してください'}),
nullAbleCheckbox: z.boolean(),
checkbox: z.boolean().refine(val => val === true, { message: "チェックしてください。"}),
nullAbleRadio: z.string(),
radio: z.string().min(1, {message: '任意の項目を選択してください。'}),
nullAbleDate: z.string(),
date: z.string().min(1, {message: '日付を入力してください。'}).refine(val => new Date(val) > new Date(), {message: "有効な日付を入力してください。"}),
nullAbleTextarea: z.string(),
textarea: z.string().min(1, {message: 'テキストを入力してください。'}),
});
※nullを許容するフォーム要素にはnullAbleを先頭に付けている。
フォームの型定義
Zodにスキーマから型を生成してくれるinfer
が提供されているので、それを使い型生成を行う。
type Schema = z.infer<typeof schema>;
defaultValueを作成する関数を作成する
Zodの.default()
でも defaultValue を定義することは可能だが、不要な再レンダリングを避けるために、RHFのdefaultValueで定義する。
また、useFormでkeyを直書きして指定するkとは可能だが、Zodのスキーマと二重管理になるため、スキーマからdefaultValueを生成し、指定する。
一旦、ZodのスキーマからdefaultValueを生成する関数を作成する。
const initFormVal = <T extends z.ZodRawShape>(schema: ZodObject<T>) => {
return Object.entries(schema.shape).reduce<Record<string, unknown>>((acc, [key, val]) => {
if (val instanceof z.ZodString) {
acc[key] = ""
} else if (val instanceof z.ZodNumber) {
acc[key] = null
} else if (val instanceof z.ZodBoolean) {
acc[key] = false
} else if (val instanceof z.ZodArray) {
acc[key] = []
} else if (val instanceof z.ZodEffects) {
if (
val._def.schema instanceof z.ZodString ||
val._def.schema._def.schema instanceof z.ZodString
) {
acc[key] = ""
} else if (val._def.schema instanceof z.ZodArray) {
acc[key] = []
} else if (val._def.schema instanceof z.ZodBoolean) {
acc[key] = false
}
} else {
acc[key] = undefined
}
return acc
}, {});
};
今回は、1つのスキーマしか作成してないですが、スケールすることを考えて、引数にスキーマを取るようにする。
RHFにZodスキーマを適用
上記で作成した関数をもとに、defaultValueを指定する。
const { ... } = useForm<Schema>({
resolver: zodResolver(schema),
defaultValues: initFormVal(schema)
})
各フォームコンポーネント作成
それぞれのフォーム要素のコンポーネントを作成する。
フォーム要素の共通ラッパー
テキスト, 数値
セレクトボックス
単体チェックボックス
複数チェックボックス
ラジオボタン
日程
テキストエリア
手順5 で作成したコンポーネントでフォーム作成
import {
Box,
Button,
Container,
Stack,
} from "@mui/material"
import {
SubmitHandler,
useForm,
} from "react-hook-form"
import { zodResolver } from '@hookform/resolvers/zod'
import { schema, type Schema } from './schema';
import RhfTextarea from './RhfTextarea';
import RhfSelect, { SelectOptions } from './RhfSelect';
import { initFormVal } from './utils';
import RhfMultiCheckbox, { MultiCheckboxOptions } from './RhfMultiCheckbox';
import RhfOneCheckbox from './RhfOneCheckbox';
import RhfTextField from './RhfTextField';
import RhfRadio, { RadioOptions } from './RhfRadio';
import RhfDatePicker from './RhfDatePicker';
const selectOptions: SelectOptions[] = [
{ label: "0", value: "0" },
{ label: "1", value: "1" },
{ label: "2", value: "2" },
{ label: "3", value: "3" },
{ label: "4", value: "4" },
{ label: "5", value: "5" },
] as const;
const checkboxesOptions: MultiCheckboxOptions[] = [
{ label: "選択肢1", value: "sentakushi1" },
{ label: "選択肢2", value: "sentakushi2" },
{ label: "選択肢3", value: "sentakushi3" },
] as const;
const radioOptions: RadioOptions[] = [
{ label: "ラジオ1", value: "radio1" },
{ label: "ラジオ2", value: "radio2" },
{ label: "ラジオ3", value: "radio3" },
] as const;
function MuiRhfWithControllerAndZod() {
const {
handleSubmit,
control,
} = useForm<Schema>({
mode: 'onSubmit', // 初回validation時を検索ボタンが押されたタイミングに設定
reValidateMode: 'onBlur', // 送信ボタンが押され、バリデーションに引っかかった後は、常に入力値のフォーカスが外れた際にバリデーションが走る
resolver: zodResolver(schema), // 外部のバリデーションスキーマを適用する
defaultValues: initFormVal(schema)
})
const onSubmit: SubmitHandler<Schema> =
(data) => console.log('data', data)
return (
<Container maxWidth="sm" sx={{ pt: 5 }}>
<Box component="form" onSubmit={handleSubmit(onSubmit)} sx={{ mt: 1 }}>
<Stack spacing={3}>
{/* テキスト */}
<RhfTextField
type='text'
name="nullAbleText"
label='テキスト'
control={control}
/>
<RhfTextField
type='text'
name="text"
label='テキスト(必須)'
control={control}
/>
{/* 数値 */}
<RhfTextField
name="nullAbleNumber"
type='number'
label='数値'
control={control}
/>
<RhfTextField
name="number"
type='number'
label='数値(必須)'
control={control}
/>
{/* セレクトボックス */}
<RhfSelect
name="nullAbleSelect"
control={control}
label="セレクトボックス"
options={selectOptions}
/>
<RhfSelect
name="select"
control={control}
label="セレクトボックス(必須)"
options={selectOptions}
/>
{/* 単体チェックボックス */}
<RhfOneCheckbox
label="チェックボックス"
name="nullAbleCheckbox"
control={control}
/>
<RhfOneCheckbox
label="チェックボックス(必須)"
name="checkbox"
control={control}
/>
{/* 複数チェックボックス */}
<RhfMultiCheckbox
label="複数チェックボックス"
name="nullAbleCheckboxes"
control={control}
options={checkboxesOptions}
/>
<RhfMultiCheckbox
label="複数チェックボックス(必須)"
name="checkboxes"
control={control}
options={checkboxesOptions}
/>
{/* ラジオボタン */}
<RhfRadio
label="ラジオボタン"
name="nullAbleRadio"
control={control}
options={radioOptions}
/>
<RhfRadio
label="ラジオボタン(必須)"
name="radio"
control={control}
options={radioOptions}
/>
{/* 日程 */}
<RhfDatePicker
name="nullAbleDate"
label='日程'
control={control}
/>
<RhfDatePicker
name="date"
label='日程(必須)'
control={control}
/>
{/* テキストエリア */}
<RhfTextarea
name="nullAbleTextarea"
label='テキストエリア'
control={control}
rows={5}
/>
<RhfTextarea
name="textarea"
label='テキストエリア(必須)'
control={control}
rows={5}
/>
<Button
type="submit"
color="primary"
variant="contained"
size="large"
>
ボタン
</Button>
</Stack>
</Box>
</Container>
)
}
export default MuiRhfWithControllerAndZod
おまけ
useForm
などのロジックをカスタムフックとしてまとめる
以上!
参考記事
Discussion