💎

ZodとReact Hook Formによる、支払い方法フォームの作成

2023/10/09に公開
1

概要

ZodとReact Hook Form(以下RHF)を使って、支払い方法ごとにバリデーションの要件が異なるフォームを作っていきます。

この記事では、例として、以下の要件を実装していきます。

要件

  • 支払い方法は、クレジットカード、コンビニ支払い、銀行支払いの3つ
  • それぞれの支払い方法はラジオボタンで選択可能
  • クレジットカードは、カード番号、氏名、有効期限、セキュリティコードについてバリデーションを行う
  • コンビニ支払いは、select boxから何かしら選択したかどうかをバリデーションする
  • submitボタンは常にenabled
  • blur時はフォーカスを外した項目のみ、submit時はラジオボタンから選択した支払い方法の全項目について、それぞれバリデーションをかける
  • エラーメッセージは該当項目にフォーカスすると消える(blur時に再度検証)

完成系

今回の完成系コードはこちらに置いています。サーバーはviteで立てています。
https://github.com/usk94/payment-method-form

見た目はこんな感じです。

支払い方法フォーム

実装

フォームの設定

まずuseFormを使って、フォームの設定を入れます。

const paymentMethods = ["card", "convini", "bank"] as const

const formMethods = useForm<FormType>({
  defaultValues: {
    selectedPaymentMethod: undefined,
    card: {
      number: undefined,
      name: undefined,
      expiry: { year: undefined, month: undefined },
      cvc: undefined,
    },
    convini: {
      conviniName: undefined,
    },
  },
  mode: "onBlur",
  resolver: zodResolver(schema),
  shouldFocusError: false,
})

各支払い方法はラジオボタンで表現するので、「今どの支払い方法が選択されているのか?」を示す意で、selectedPaymentMethodという値を設けます。
selectedPaymentMethod含め、バリデーションに必要な、カード、コンビニ支払いで必要なそれぞれの値は、ひとまずundefinedにしておきます。

submit時だけでなく、blurにもバリデーションを走らせて欲しいので、modeはonBlurです。

resolverに食わせているschemaは、後述するバリデーションロジックが記述してあるもので、FormTypeはそのschemaから推測したフォーム全体の型となります。

フォーム

こちらがフォームを表示しているコンポーネントです。

const App = () => {
  const formMethods = useForm<FormType>({ ...})
  const onSubmit = (data: FormType) => console.log(data)
  const selectedPaymentMethod = useWatch({ control: formMethods.control, name: "selectedPaymentMethod" })
  const isActive = (paymentMethod: PaymentMethodType) => paymentMethod === selectedPaymentMethod

  return (
    <FormProvider {...formMethods}>
      <form
        onSubmit={formMethods.handleSubmit(onSubmit)}
        className="flex flex-col w-screen h-screen justify-center items-center"
      >
        <p className="text-xl font-semibold">支払い方法</p>
        <ErrorMessage
          errors={formMethods.formState.errors}
          name="selectedPaymentMethod"
          render={() => <p className="text-red-500 text-base">いずれかを選択してください</p>}
        />
        <div className="mt-2 flex flex-col gap-y-4 w-80">
          {paymentMethods.map((method) => {
            switch (method) {
              case "card":
                return <Card isActive={isActive(method)} />
              case "convini":
                return <Convini isActive={isActive(method)} />
              case "bank":
                return <Bank />
            }
          })}
        </div>

        <button type="submit" className="mt-4 border border-blue-500 rounded text-blue-500">
          submit!
        </button>
      </form>
    </FormProvider>
  )
}

onSubmitはバリデーションを突破した時に実行されますが、今回はバックエンドとの連携までやる必要はないのでとりあえずフォームのデータをログに出しておきます。

また、今フォームの中でどの支払い方法をユーザーは選んでいるのか?を認識するためにisActiveという関数を用意しています。

isActiveで使用している、現在ユーザーが選んでいるselectedPaymentMethodはuseWatchで取得しています。

他の取得方法としてwatchとgetValuesもありますが、watchだとform全体を再レンダリングしてしまうためパフォーマンスに響くというのと、getValuesだとユーザーイベントによる変更を購読しないので、useWatchにしました。
useWatchは、使っているコンポーネントのみ再レンダリングし、ユーザーが支払い方法のラジオボタンをぽちぽち押したことを都度汲み取ってくれます。

https://scrapbox.io/mrsekut-p/getValuesとwatchとuseWatachの使い分け

FormProviderを使っているのは、フォームの中の各支払い方法コンポーネントでuseFormContextを使いたかったためです。
今回の構造はそこまでネストされていないので、普通にpropsとして渡してもよかったのですが、とはいえregisterなどをそのまま渡すのは不恰好だろうと思いました。

それでは、クレジットカードコンポーネントについて見ていきます。

クレジットカード

コードが長いのでアコーディオンにします。

クレカのコード
const monthOptions = [...Array(12)].map((_, index) => ({
  value: `0${index + 1}`.slice(-2),
  label: `0${index + 1}`.slice(-2),
}))

const yearOptions = [...Array(10)].map((_, index) => ({
  value: String(currentYear + index),
  label: String(currentYear + index),
}))

export const Card = ({ isActive }: { isActive: boolean }) => {
  const {
    formState: { errors },
    register,
    control,
  } = useFormContext<FormType>()

  const year = useWatch({ control: control, name: "card.expiry.year" })
  const month = useWatch({ control: control, name: "card.expiry.month" })

  let path = ""
  if (!year) {
    path = "card.expiry.year"
  } else if (!month) {
    path = "card.expiry.month"
  }

  return (
    <label className="flex w-full rounded-lg border py-4 px-4 border-gray-200 gap-y-4">
      <input type="radio" value="card" {...register("selectedPaymentMethod")} />
      <div className="ml-4 flex-grow">
        <p className="text-sm font-semibold text-black">カード</p>
        {isActive && (
          <>
            <div className="pb-3 mt-3 border-t border-gray" />
            <div className="flex flex-col gap-y-3">
              <div className="flex flex-col gap-y-1">
                <p className="text-sm">カード番号</p>
                <input {...register("card.number")} className="h-8 border border-gray-300 rounded" />
                <ErrorMessage
                  errors={errors}
                  name="card.number"
                  render={({ message }) => <p className="text-red-500 text-xs">{message}</p>}
                />
              </div>
              <div className="flex flex-col gap-y-1">
                <p className="text-sm">氏名</p>
                <input {...register("card.name")} className="h-8 border border-gray-300 rounded" />
                <ErrorMessage
                  errors={errors}
                  name="card.name"
                  render={({ message }) => <p className="text-red-500 text-xs">{message}</p>}
                />
              </div>
              <div className="flex flex-col gap-y-1">
                <p className="text-sm">有効期限</p>
                <div className="flex gap-x-2">
                  <select
                    {...register("card.expiry.year")}
                    defaultValue=""
                    autoComplete="cc-exp-year"
                    className="w-1/2 h-8 border rounded border-gray-300"
                  >
                    <option key="default" value="" disabled></option>
                    {yearOptions.map((option) => (
                      <option key={option.value} value={option.value}>
                        {option.label}
                      </option>
                    ))}
                  </select>
                  <select
                    {...register("card.expiry.month")}
                    defaultValue=""
                    autoComplete="cc-exp-month"
                    className="w-1/2 h-8 border rounded border-gray-300"
                  >
                    <option key="default" value="" disabled></option>
                    {monthOptions.map((option) => (
                      <option key={option.value} value={option.value}>
                        {option.label}
                      </option>
                    ))}
                  </select>
                </div>
                <ErrorMessage
                  errors={errors}
                  name={path}
                  render={({ message }) => <p className="text-red-500 text-xs">{message}</p>}
                />
              </div>
              <div className="flex flex-col gap-y-1">
                <p className="text-sm">セキュリティコード</p>
                <input {...register("card.cvc")} className="h-8 border border-gray-300 rounded" />
                <ErrorMessage
                  errors={errors}
                  name="card.cvc"
                  render={({ message }) => <p className="text-red-500 text-xs">{message}</p>}
                />
              </div>
            </div>
          </>
        )}
      </div>
    </label>
  )
}

各項目ごとに、文言、inputまたはselect、場合に応じてエラーメッセージを表示しています。

特筆すべき点としては(そんなに大した話でもないですが)、まず、isActiveを受け取って、その真偽値次第で、クレカフォームを出すか否かを決めています。つまり、クレカのラジオボタンを押したらクレカフォームが現れるようになっています。

また、ErrorMessageコンポーネントを @hookform/error-message からimportしています。
errorsを食わせてnameを指定したら、よしなにエラーメッセージを表示してくれるので便利でした。

https://react-hook-form.com/docs/useformstate/errormessage

加えて、yearとmonthをuseWatchで取得して、その後妙なif文を使っているのは、有効期限という項目が、yearとmonthの2つの値をもとにして1つのエラーメッセージを表示するという要件だったためです。
この辺は後述するzodの書き方にも関わってくるのですが、この、値とエラーメッセージの表示が単純に1:1になっていない構図が自分には少し難しく、妙な書き方となりました。

どなたかいい書き方があれば教えてください🙇‍♂️🙇‍♂️

コンビニ支払いと、銀行支払いは全然特筆すべきことがないのでスキップします。

schema

それでは最後に、バリデーションのロジックを作っていきます。

支払い方法により求められるバリデーションの内容が変わる、というのが要件のミソなので、そこをどう実装するか考えます。

refineやsuperRefineでif文を使ってごりごり書いてしまうというのも想像しましたが、それらを使うほど込み入った要件でもなく、単純なプリミティブな値の分岐で表現可能なので、ここは下記の記事を参考にしてz.discriminatedUnionを使います。

https://azukiazusa.dev/blog/react-hook-form-zod-5-patterns/

z.unionでも実装可能でしたが、z.discriminatedUnionの方がパフォーマンスにおいてより優れているようです。
https://github.com/colinhacks/zod/pull/899

selectedPaymentMethodをキーとして、cardのときはカード番号, 氏名, 有効期限, セキュリティコードをそれぞれバリデーションします。
氏名は単純に入力してさえいればいいので単純ですが、それ以外の項目は、適宜クレジットカードの仕様に基づいたバリデーションが必要です(ここでは深掘りしません)。

conviniのときは、選択された値の内容がConviniNameという型のいずれかと一致するかをバリデーションします。

bankは特にバリデーションするものはないので、選択した時点で通過できるようにします。

z.discriminatedUnionを使うことによって、バリデーションの分岐を比較的わかりやすく可視化できているかと思います。

type ConviniName = "familyMart" | "sevenEleven" | "lawson"

export const schema = z.discriminatedUnion("selectedPaymentMethod", [
  z.object({
    selectedPaymentMethod: z.literal("card"),
    card: z.object({
      number: z.string().refine(isValidCreditCardNumber, { message: "カード番号が正しくありません" }),
      name: z.string().min(1, { message: "名前を入力してください" }),
      expiry: z.object({
        year: z.string().nonempty("選択してください"),
        month: z.string().nonempty("選択してください"),
      }),
      cvc: z.string().refine(isValidCvc, { message: "正しいCVCを入力してください" }),
    }),
  }),
  z.object({
    selectedPaymentMethod: z.literal("convini"),
    convini: z.object({
      conviniName: z.custom<ConviniName>(),
    }),
  }),
  z.object({
    selectedPaymentMethod: z.literal("bank"),
  }),
])

gif

こちらは完成系のgifです(なんか動きが遅い🤔)

所感

選択しているプランによって支払い方法のバリデーション内容がさらに変わるとか、もう少し複雑な要件で作ってみるのもおもしろそうだと思いました。

参考記事

https://qiita.com/kalbeekatz/items/09df07f78420ab6b6e57

Discussion

nap5nap5

yearとmonthの2つの値をもとにして1つのエラーメッセージを表示するという要件だったためです。
どなたかいい書き方があれば教えてください🙇‍♂️🙇‍♂️

このようにスキーマ定義してみたら、いいかなと思いました
おそらく、やりたいことはできているんじゃないかと思います
なるたけ、zod側のスキーマ定義に寄せる形となります

  expiry: z.object({
    year: z.custom<string>(),
    month: z.custom<string>(),
  }).superRefine((values, ctx) => {
    const { year, month } = values;
    if (year == null || year === "") {
      ctx.addIssue({
        code: "custom",
        fatal: true,
        message: "年を選択してください",
      });

      return z.NEVER;
    }

    if (month == null || month === "") {
      ctx.addIssue({
        code: "custom",
        fatal: true,
        message: "月を選択してください",
      });

      return z.NEVER;
    }
  });

z.customはバリデーションをスキップする作りらしいので、今回のようにDiscriminationでFillOutするタイプが変わる形式ですと、選択切り替えごとのデフォ値の設定が難しくなるので、customにしています(使うライブラリによってはwarnログが出るなど)
ただ、フォームのUIによるとは思います

そのうえで、superRefineで2つの集約単位であるexpiryに対して、バリデーションを設定しています