🐡

MUI + React Hook Form + Zod でフォーム作成

2024/02/17に公開

はじめに

今回作るMUI + React Hook Form + Zodのソースコードは↓こちらです。
https://github.com/49takaya3989/sample_react_mui_react-hook-form/tree/main/src/MUI%2BRHF_with_controller_and_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 ファーストのスキーマ宣言および検証ライブラリです。私は、単純なオブジェクトから複雑なネストされたオブジェクトまで、あらゆるデータ型を広く指すために「スキーマ」という用語を使用しています

https://zod.dev/

なぜReact Hook FormとZodを組み合わせるのか?

React Hook Formにもvalidate apiが存在し、バリデーションロジックを書くことは可能だが、他のフォームの値を参照するようなロジックを実装しようとすると、コンポーネント側に書く必要が出てきて、コードの分離が難しくなる。
つまり、ロジックの使い回しができなかったりテストも書きにくくなったりする。そのためzodのようなバリデーションライブラリを用いてロジックの分離を行う。

MUI と React Hook Form と Zod でフォーム作成する

  1. Zodでスキーマの定義
  2. フォームの型定義
  3. defaultValueを作成する関数を作成する
  4. RHFにZodスキーマを適用
  5. 各フォームコンポーネント作成
  6. 手順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などのロジックをカスタムフックとしてまとめる
https://github.com/49takaya3989/sample_react_mui_react-hook-form/blob/main/src/MUI%2BRHF_with_controller_and_zod/useSampleForm.ts
https://github.com/49takaya3989/sample_react_mui_react-hook-form/blob/main/src/MUI%2BRHF_with_controller_and_zod/index.tsx
以上!

参考記事
https://tech.buysell-technologies.com/entry/adventcalendar2022-12-24
https://zenn.dev/monicle/articles/4868424f22d6f5

Discussion