💳

複雑な支払いフォームをZodとReact Hook Formで実現する

2023/11/08に公開

よく見る支払いフォーム

ここ最近、支払いフォームを実装する機会があり、その際に割と悩んで勉強になったため自分なりの解決策をシェアしようと思います。

そしてちょうどこちらの記事で同じような要件の実装をされていました。非常に参考になったので掲載させていただきます。

なお今回はこの要件に追加でカード選択の要件が加わったフォームを実装していきます。ぜひ最後までご覧ください。

https://zenn.dev/rinda_1994/articles/866e56d744d58c

フォームの要件

  • 支払い方法は大きく分けて以下の2つが存在する
    • ポイント払い
    • クレジットカード払い
  • クレジットカード払いは大きく分けて以下の2つが存在する
    • 登録済みのカードで支払う
    • 新しくカード情報を入力して支払う
  • 登録済みのカードは最大3枚まで表示できる(3枚まで登録できる)
  • 登録済みのカード情報はデータベースから取得し、以下の情報を持つ
    • カードID
    • カード名義人
    • カード番号の下4桁
    • 有効期限
  • 新規カードで支払う場合は以下の情報を入力する
    • カード番号
    • カード名義人
    • 有効期限
    • セキュリティコード
  • その他の要件として以下も含む
    • 新規カード情報入力後、支払い方法を選択しなおしてもフォームの値が保持されている
    • クレジットカード払い選択時のみ、配下の選択肢が表示される
    • 新規カード選択時のみ、カード情報入力フォームが表示される

UI動作イメージ

UI動作イメージ

それぞれの支払い方法について要件を満たしていることを確認できると思います。

ポイント払い

  1. ポイント払いを選択
  2. 支払いボタンを押下
  3. 支払い成功

登録済みカード払い

  1. カード払いを選択
  2. 先頭のカードをデフォルトで選択済み
  3. 支払いボタンを押下
  4. 支払い成功

新規カード払い

  1. 新規カード払いを選択
  2. 支払いボタンを押下
  3. フォームバリデーションエラー
  4. フォーム入力
  5. 支払いボタンを再度押下
  6. 支払い成功
  7. 再度、カードを選択しなおしてもカードの情報が保持されている

作成したスキーマ

上記の要件に対して私は以下のようなスキーマを作成しました。

schema.ts
import { z } from "zod";

const cardSchema = z.object({
  id: z.string(),
  maskedCardNumber: z.string(),
  goodThru: z.string(),
  cardHolderName: z.string(),
  // to hold new card data
  cardNumber: z.string().optional(),
  cardHolder: z.string().optional(),
  expiryDate: z.string().optional(),
  securityCode: z.string().optional(),
  save: z.boolean().optional(),
});

const pointSchema = z.object({
  paymentMethod: z.literal("point"),
});

const creditCardSchema = z.object({
  paymentMethod: z.literal("card"),
  card: z.discriminatedUnion("order", [
    // existing card
    cardSchema.merge(
      z.object({
        order: z.literal("first"),
      })
    ),
    cardSchema.merge(
      z.object({
        order: z.literal("second"),
      })
    ),
    cardSchema.merge(
      z.object({
        order: z.literal("third"),
      })
    ),
    // new card
    z.object({
      order: z.literal("new"),
      cardNumber: z.string().min(1, { message: "カード番号は必須項目です" }),
      cardHolder: z.string().min(1, { message: "カード名義人は必須項目です" }),
      expiryDate: z.string().min(5, "有効期限は必須項目です"),
      securityCode: z.string().min(3, "セキュリティコードは必須項目です"),
      save: z.boolean(),
    }),
  ]),
});

export const schema = z.discriminatedUnion("paymentMethod", [
  pointSchema,
  creditCardSchema,
]);

export type SchemaType = z.infer<typeof schema>;

ポイント払いとカード払いはdiscriminatedUnionで区別

まず1つ目の分岐であるポイント払いとカード払いはdiscriminatedUnionを使用することで対応しました。
discriminatedUnionは指定したフィールドの値によってスキーマを変更できるZodのAPIです。

今回の場合、引数であるpaymentMethodの値によってpointSchemacreditCardSchemaのどちらかのスキーマが適用される形となります。

この選択肢をラジオボタンに反映することで実現できます。この手法は大変便利で、冒頭で紹介した記事でも使用されていました。

登録済みのカードはliteralで管理

今回、登録可能なカード枚数が有限(3枚)という仕様だったのでカードを1枚ずつオブジェクトとして管理し、それらにorderというフィールドを持たせました。これはラジオボタンで選択する際に何番目のカードを選択したかを判別するためです。

そして同階層ordernewというオブジェクトを用意しておくことで新規カードにも対応しました。

orderはz.literalでfirstsecondethirdという風にカード枚数が有限であることを活かして設計しました。

カード情報保持のために余分なフィールドを追加

新規カード情報入力後に、支払い方法を変更しても入力したフォームの値が保持されるようにcardSchema(登録済みカードに使用するスキーマ)に余分なフィールドを追加しています。

具体的にはスキーマのcardNumber以下のフィールドになります。
このように登録済みカードのスキーマに余分なフィールドを追加することで入力カード情報を保持するという要件を満たしています。

またこうすることで他のstate管理を用いなくて良くなることも魅力的です。

cardSchema
const cardSchema = z.object({
  id: z.string(),
  maskedCardNumber: z.string(),
  goodThru: z.string(),
  cardHolderName: z.string(),
  // to hold new card data
  cardNumber: z.string().optional(),
  cardHolder: z.string().optional(),
  expiryDate: z.string().optional(),
  securityCode: z.string().optional(),
  save: z.boolean().optional(),
});

実装する上で考慮したこと

カード枚数が有限なこと

今回は登録済みカードの枚数が決まっていたため上記のスキーマで対応できました。
ラジオボタンをスキーマにする際にunionまたはdiscriminatedUnionで区別することは広まっている手法ですが今回の場合、

  • 登録済みカード払い
  • 新規カード払い

の2つが並列であると同時に、登録済みカードが複数枚あるという仕様だったのでスキーマをどう設計しようか悩みました。

カード枚数が有限であったため上記のスキーマが使えますが、有限でない場合は他の手法を考える必要があると思います。

登録済みカードの初期化

スキーマとは直接関係ないですが、フォームを作成する上で大切なのが初期値の設定です。
詳細は最後に載せているリポジトリを見ていただければより明確になると思うのですが、今回はuseEffectを用いて擬似的なデータフェッチを再現しました。

useEffect
useEffect(() => {
  // 本来はAPIから取得する
  setCards([
    {
      id: "card-id1",
      maskedCardNumber: "**** **** **** 1111",
      goodThru: "12/27",
      cardHolderName: "SUZUKI JIRO",
    },
    {
      id: "card-id2",
      maskedCardNumber: "**** **** **** 2222",
      goodThru: "11/27",
      cardHolderName: "TAKEUCHI SABURO",
    },
  ]);

  // Apollo Clientの場合、onCompletedで実行する
  methods.reset({
    paymentMethod: undefined,
    card: {
      order: "first",
      id: "card-id1",
      maskedCardNumber: "**** **** **** 1111",
      cardHolderName: "SUZUKI JIRO",
      goodThru: "12/27",
      save: true,
    },
  });
}, []);

実際の環境ではApollo Clientを使っていて、登録済みカードがあった場合にはQueryのonComplete内でフォームの初期値をリセット(再設定)しています。

入力カード情報を保持する

新規カードフォームに入力された値を保持するためにカード払い配下のラジオボタン選択時に以下の処理をしています。

ラジオボタン選択時
setValue(
  "card.order",
  e.target.value as "first" | "second" | "third" | "new"
);

const selected = cards.find((card) => card.order === e.target.value);
if (selected) {
  const cardValues = getValues("card");
  setValue("card", {
    ...cardValues,
    ...selected,
  });
}

cardsは以下のような型を持ち、登録済みのカードを表します。orderにnewがないのがポイントです。
上記の処理により登録済みのカードが選択されたときは"cardValues"(=新規カード情報)をフォーム内に保持することを実現しています。

cardsの型
{
  id: string;
  maskedCardNumber: string;
  goodThru: string;
  cardHolderName: string;
  order: "first" | "second" | "third";
}[]

さいごに

初めてこのような複雑な支払いフォームを実装し、とても興味深く勉強になりました。
またシンプルにカードフォームを実装でき、zodの可能性を非常に感じる機会にもなりました。

スキーマについてもっとこうした方が良いなどの意見ありましたらぜひ教えてください!

今回使ったUI、スキーマなどは全て以下のリポジトリにあります。
Next.js14を使ってデモを実装しています。ぜひご覧ください。
https://github.com/fujiyamaorange/payment-form

参考

https://flowbite.com/docs/components/forms/

https://zenn.dev/rinda_1994/articles/866e56d744d58c

Discussion