LoginSignup
9

posted at

updated at

react-hook-form と Zod で条件分岐のあるフォームを構築する方法6選

概要

React & TypeScript で開発しているプロダクトの場合、フォームライブラリに react-hook-form 、バリデーションライブラリに Zod を選択するシーンが増えているように思います。
https://qiita.com/kjkj_ongr/items/0eff5173b6e4fce7fbe8
https://qiita.com/ka2_75/items/847e59b30495e32c0bf8

フォームの仕様で、「こっちの項目がこの値の時だけ、あっちの項目を表示する/バリデーションを変える」といった条件分岐をよく見かけます。

本記事では、 react-hook-form と Zod でバリデーションの条件分岐を実装する方法を紹介します。
サンプルリポジトリ: https://github.com/kalbeekatz/react-hook-form_zod

結論を先に知りたい方へ

react-hook-form と Zod でバリデーションの条件分岐を実装する代表的な方法に、

があり、 基本的には .discriminatedUnion を使って実装するのがおすすめですが、条件分岐を literal で表現しづらい場合は .refine を使うことも検討しましょう。
サンプルリポジトリ: https://github.com/kalbeekatz/react-hook-form_zod

執筆時点でのライブラリなどのバージョン

React 18.2.0
react-hook-form 7.40.0
Zod 3.19.1

.discriminatedUnion は v3.12.0 以降

.discriminatedUnion は v3.12.0 で 2022/2/25 にリリースされたメソッドです。
https://github.com/colinhacks/zod/releases/tag/v3.12.0
それより前のバージョンで条件分岐を実装するには .union.superRefine を使うことになります。

条件分岐のサンプル実装

まずは完成形のキャプチャです。
条件分岐.gif
サンプルリポジトリ: https://github.com/kalbeekatz/react-hook-form_zod

簡素なフォームですが、「犬が好き」にチェックを入れると「好きな犬種」欄が表示される条件分岐が実装されています。

また、バリデーションにも条件分岐があり、「犬が好き」にチェックが入っている場合「好きな犬種」欄は必須項目となり、未入力の場合は「入力してください」とバリデーションエラーが表示されます。

この時送信ボタンは非活性となっていますが、バリデーションエラーがある状態のままであっても「犬が好き」のチェックが外れると送信ボタンは活性となります。

「犬が好き」のチェックが外れている場合は「好きな犬種」欄は必須項目ではなくなっています。

サンプルコード

この条件分岐を実装できる Zod のメソッドは6つ見つかりましたが、2つのグループに大別できます。
(他の方法を知っていたら教えて下さい。)

  • Union パターンのグループ
    • .union.discriminatedUnion.or のいずれかを使う
    • どのメソッドも判別可能な Union 型になり、型上も条件分岐される
  • 変換パターンのグループ
    • .refine.superRefine.transform のいずれかを使う
    • 型は条件分岐されない

フォームのサンプルコード

フォームのマークアップは下記のようになります。

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { InputSchema, schema } from "./schema";

export default function Form() {
  const {
    register,
    watch,
    formState: { errors, isValid },
    handleSubmit,
  } = useForm<InputSchema>({
    defaultValues: { name: "", love_dog: false, type: "" },
    resolver: zodResolver(schema),
    mode: "onChange",
    shouldUnregister: true,
  });

  const love_dog = watch("love_dog");

  const onSubmit = handleSubmit((values) => {
    console.log(values);
  });
  return (
    <form onSubmit={onSubmit}>
      <label>
        名前:
        <br />
        <input {...register("name")} />
      </label>
      <label>
        <input type="checkbox" {...register("love_dog")} />
        犬が好き
      </label>
      {love_dog && (
        <label>
          好きな犬種:
          <br />
          <input {...register("type")} />
          <span>{errors.type?.message}</span>
        </label>
      )}
      <button disabled={!isValid}>送信</button>
    </form>
  );
}

以降の説明では、 ./schema をそれぞれのパターンのコードに置き換えるイメージとなります。

Union パターンの schema のサンプルコード

.union

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

const baseSchema = z.object({
  name: z.string(),
});

const conditionalSchema = z.union([
  z.object({
    love_dog: z.literal(true),
    type: z.string().min(1, "入力してください"),
  }),
  z.object({
    love_dog: z.literal(false),
    type: z.string().optional(),
  }),
]);

export const schema = baseSchema.and(conditionalSchema);
export type InputSchema = z.infer<typeof schema>;

.union の引数は配列になっていて、分岐する条件に合わせて .object を定義します。
love_dog は「犬が好き」のチェックボックスの値なので通常 boolean 型で定義しますが、 z.literal(true)true に限定することができます。
こうすることで、 love_dogtrue の時は type が必須項目になり、 love_dogfalse の時は type が optional になる条件分岐が作れます。
これはバリデーション上の条件分岐だけでなく、 z.infer で得られる型も条件分岐されます。
判別可能な Union 型として扱うことができます。

.or

.or.union と機能的には変わりませんが、配列ではなくメソッドチェーンで記述することができます。

z.object({
  love_dog: z.literal(true),
  type: z.string().min(1, "入力してください"),
}).or(
  z.object({
    love_dog: z.literal(false),
    type: z.string().optional(),
  })
);

.discriminatedUnion

Zod には .union と似た .discriminatedUnion というメソッドがあります。

z.discriminatedUnion("love_dog", [
  z.object({
    love_dog: z.literal(true),
    type: z.string().min(1, "入力してください"),
  }),
  z.object({
    love_dog: z.literal(false),
    type: z.string().optional(),
  }),
]);

配列で条件分岐が指定できるのは .union と変わりませんが、第一引数が文字列になっているのがポイントです。
この文字列には「分岐の条件とする項目の name 値」を設定します。
得られる型も、バリデーションロジックも .union の場合と変わりません。
何が変わるかと言うとパフォーマンス(処理速度)です。
.discriminatedUnion が導入された pull リクエストにはベンチマークが記載されています。
https://github.com/colinhacks/zod/pull/899

Union パターンでやりづらいこと

分岐の条件を literal で表現できない場合は、型の旨味を享受することができません。
また、分岐の条件が複数にまたがっている場合、 Union の構造が複雑になったりコードが冗長になるかもしれません。
その場合は、後述の変換パターンで実装するのがよい場面もありそうです。

変換パターンの schema のサンプルコード

.refine

.refine は対象の schema を再定義するメソッドで、後述の .superRefine を手短に書くための糖衣構文です。
第一引数のコールバックの引数に love_dogtype それぞれの値を受け取り、コールバックが false を返却する場合は、第二引数の指定にしたがってバリデーションエラーとなります。

z.object({
  love_dog: z.boolean(),
  type: z.string().optional(),
}).refine(({ love_dog, type }) => !love_dog || (love_dog && !!type), {
  path: ["type"],
  message: "入力してください",
});

.superRefine

.superRefine.refine と同様に対象の schema を再定義するメソッドです。
.refine は一つの項目のバリデーションエラーしか指定できませんが、 .superRefine.addIssue を複数回呼び出すことで、複数の項目に渡ってバリデーションエラーを指定することができます。

z.object({
  love_dog: z.boolean(),
  type: z.string().optional(),
}).superRefine(({ love_dog, type }, ctx) => {
  if (love_dog && !type) {
    ctx.addIssue({
      path: ["type"],
      code: z.ZodIssueCode.too_small,
      minimum: 1,
      type: "string",
      inclusive: false,
      message: "入力してください",
    });
  }
});

.transform

.transform.superRefine に加えてさらに値の変換ができるメソッドとなっています。

z.object({
  love_dog: z.boolean(),
  type: z.string().optional(),
}).transform(({ love_dog, type }, ctx) => {
  if (love_dog && !type) {
    ctx.addIssue({
      path: ["type"],
      code: z.ZodIssueCode.custom,
      message: "入力してください",
    });
  }
  return {
    love_dog,
    type,
  };
});

変換パターンでやりづらいこと

  • 型の恩恵が得られない
    Union パターンは判別可能な Union 型を得ることができますが、変換パターンはメソッドの呼び出し元の型定義が引き継がれてしまいます。
  • Zod のメソッドが使いづらい
    .addIssue の記述は冗長に感じます。
    また、せっかく Zod を使用しているのに、値の検査に Zod のメソッドが使いづらいです。

余談: 他のオブジェクトとの統合には .and を使う

オブジェクト schema の結合には .merge.and.intersection があります。
下記の例を見ると、 baseSchema はオブジェクト schema ですが、 conditionalSchema は Union schema です。
この場合、 .merge で統合することはできません。
.merge はオブジェクト同士で使用するためです。
他のオブジェクトとの統合には .and.intersection を使いましょう。

import { z } from "zod";

const baseSchema = z.object({
  name: z.string(),
});

const conditionalSchema = z.union([
  z.object({
    love_dog: z.literal(true),
    type: z.string().min(1, "入力してください"),
  }),
  z.object({
    love_dog: z.literal(false),
    type: z.string().optional(),
  }),
]);

export const schema = baseSchema.and(conditionalSchema);
export type InputSchema = z.infer<typeof schema>;

.intersection は得られる schema と型は .and と同じですが、書き方が異なります。

export const schema = z.intersection(baseSchema, conditionalSchema);

指定する schema は2つに限られるので、3つ以上の schema を統合することはできません。

fooSchema
  .and(barSchema)
  .and(hogeSchema)
  .and(piyoSchema)

後で3つ目を追加で統合できるように初めから .and を使っておくのがよさそうです。

まとめ

react-hook-form と Zod で条件分岐を実装するのは、いくつかの方法があります。
それぞれの方法にできること/できないこと、やりやすいこと/やりづらいことがあるので適宜ベストなやり方を選択して実装する必要があります。
他にもこのような方法もあるといったコメントをお待ちしております🙇‍♂️

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
9