2022.11.02

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

最近ReactやNext.jsでウェブアプリを作るときはMantineとReact Hook FormとZodの組み合わせにハマっています<br> いろんなプロジェクトで似たものを何度か実装していたのでやり方を記録として残します<br>

つくったもの

今回はこれらを使ってサインアップ画面を作ってみました

mantine-rhf-zod-signup-form

バリデーションに引っかかるとエラーメッセージが表示されます

mantine-rhf-zod-signup-form-error

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はコンポーネント数が多く、見た目もかわいいです。DatePickerMultiSelectはもちろん、DropzoneSpotlightなどのニッチなコンポーネントまであります。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}
    />
  )}
/>