🐙

ZodとReact Hook Formを使ったダイナミックなフォームの構築

2024/12/20に公開

こんにちは。もう12月ですね。1年ってはやい。
スペースマーケットでエンジニアをしている8zkです。

React Hook Formとは

Reactのフォームを簡単に構築するためのライブラリです。
https://react-hook-form.com/
類似のライブラリだとFormikreact-final-formなどがあります。

これらのライブラリの中でReact Hook Formを選んだ理由は下記の通りです。

  • ダウンロード数が多い
  • Star数が多い
  • 軽量

ダウンロード数はFormik、react-final-formなどと比べてもダントツで多くなっています。
https://npmtrends.com/formik-vs-react-final-form-vs-react-hook-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を検知するerrorsformState)を返します。

上記のコードの場合だとユーザーが何も入力せずにsubmitを押下した場合、lastNameageにエラーとしてLast name is required.Please enter number for age.というテキストを表示することができます。

詳しくは弊社 wharaguchiさんのブログがありますので、ぜひ合わせてお読みください!
https://zenn.dev/spacemarket/articles/271cf9ec2513a7

Zodとは

TypeScript Firstなバリデーションライブラリです。
https://zod.dev/

React Hook Formに対応しているバリデーションライブラリは、YupZodJoiVestなどが挙げられます。
https://react-hook-form.com/docs/useform#resolver

ダウンロード数はZod、Joi、Yup、Vestの順となっています。
https://npmtrends.com/joi-vs-vest-vs-yup-vs-zod

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を触りましたが、やりたいことは全て表現できることがわかりました。
また、ライブラリを使っている人が多く、つまづいた時には色々な記事で先人たちの知恵をお借りすることができて本当に助けられました。
同じように初めて触る、つまづいた人のお力になれたら幸いです!

さいごに

スペースマーケットでは、一緒にサービスを成長させていく仲間を探しています。
話を聞いてみたい、ちょっとだけ興味がある、などでも大歓迎です!
ご興味ありましたら是非ご連絡ください!

スペースマーケット Engineer Blog

Discussion