⚛️

実録 React Hook Form x Zodによるフォームリプレイス

2024/04/12に公開

はじめに

直近半年くらい、React Hook FormとZodの組み合わせで既存のフォームをリプレイスする作業に取り組んでいました。
今回は、その過程で溜まってきた個人的なTipsをざっくばらんに共有できればと思います。
同じような記事は巷に沢山ありますが、実プロダクトをデグレードなく移行するには詰まりどころも多く、適当な記事がヒットしないことも多かったため、同じような境遇の方のヒントとなれば幸いです。

useFormのラップ

まず、以下の記事を大変参考させていただきました。

https://zenn.dev/yuitosato/articles/292f13816993ef

こちらはZodではなくyupなのですが、React Hook Formに関する部分で以下を取り入れています。

  • useFormをラップして、defaultValuesをタイプセーフにする
  • 既存のコンポーネントをラップした~ControlというReact Hook Form依存のコンポーネントを用意する

詳しくはリンク先の記事をご覧いただければと思いますが、特に~Controlのprops定義にジェネリクスを使っていることにより、呼び出し元でname属性をタイプセーフにできる点が大きいです。

// 定義元
type Props<T> = {
  name: FieldPath<T>;
  control: Control<T>;
};

// 呼び出し元
// controlにZodのschema情報が入っているので、nameの文字列のパスが一致していないと型エラーとなる
<InputControl name="hoge.fuga" control={control} />;

以下ではできる限りこの恩恵に預かるための工夫が出てきます。

Zodのアトミックなスキーマ定義を1箇所で管理

Zodのインターフェースは、メソッドチェーンでスキーマを定義していくため初心者にも分かりやすい反面で、特にフロントエンド専任エンジニアがいない環境だったため、論理的に同じ意味合いのフォームでも定義が揺れそうという懸念がありました。
例えば、バリデーションが微妙に異なるなどです。

そこで、それ以上分解できない汎用的な文字列や数値のスキーマを変数で定義しておき、各画面側ではなるだけこちらを利用するようにしました。

// 定義元
export const requiredString = z.string().min(1);
export const emptyPermitString = z.string();
export const requiredInt = z.number().int();

// 利用側
const someSchema = z.object({
  name: requiredString,
  description: emptyPermitString,
  age: requiredInt,
});

not nullだが初期値はnull

こちらの実用的な例の一つとしては、初期値としてはnullにしておきたいけど、最終的にはnot nullのバリデーションを行いたいというケースです。
具体的には、number型として取り回したい(type='number'のinputや、idのselectなど)けど、初期値は空なのでnullとしたい場合です。
string型であれば、空文字を初期値としておいてmin(1)のバリデーションを入れれば済みますが、numberだと暗黙的な空を意味する値を定義するのもよくないので、nullを初期値としたいケースがあると思います。
しかしながら、z.int().nullable()としてしまうと、バリデーションは効きませんし、z.int()だけだと初期値を定義する部分で型エラーになってしまいます。

そこで以下のような書き方をしました。
これも先ほどの型と同様に1箇所に定義をしておきます。

export const requiredIntDefaultNull = z
  .int()
  .nullable()
  .transform((value, ctx): number => {
    if (value == null) {
      ctx.addIssue({
        code: "invalid_type",
        expected: "number",
        received: "null",
      });
      return z.NEVER;
    }
    return value;
  });

条件によって分岐するフォームの書き方

ここからは特に難しかったところです。

あるフィールドの値によって、フォームの構成要素が動的に変化するようなものは現実によくあると思います。
そのようなフォームの実装方法もいくつか参考になるものはあったのですが、上記の~Controlによるタイプセーフな実装と両立していくには難しい点がいくつかありました。

まず、単純にrootに並べて書くと失敗します。
値によってはrequiredでも、それ以外の場合はフィールドが存在しないため意図せずバリデーションエラーとなってしまうためです。
この場合の解決方法としては、discriminatedUnionを使う方法があります。
以下のように列挙的定義することができ、byType.type="timestamp"の時だけbyType.formatがrequiredなことを保証できます。

// これは途中の実装です(後述の問題あり)
const someSchema = z.object({
  byType: z.discriminatedUnion('type', [
    z.object({
      type: z.literal('string'),
      value: requiredString,
    }),
    z.object({
      type: z.enum(['timestamp']),
      value: requiredString
      format: requiredString,
    }),
  ]),
})

このようなフォームが沢山あったため、命名でいちいち悩まないように、一律でby<切り替えに関連するフィールド名>という規則を採用しました。

これで解決したように思えますが、まだいくつか問題が発生します。

  • 非直感的な初期化処理が必要になる
  • errorsを取り出す際に型エラーになる

例えば上記例で初期値がbyType.type=stringの場合、defaultValuesを指定する際に以下のようにします。

{
  byType: {
    type: "string",
    value: ""
  }
}

ですが、実行時にbyType.typeの変更が走ると、byType.formatundefinedになってしまいます。
利用コンポーネント側で、uswWatch,useEffectを組み合わせて、値の変更に伴い初期化するコードを書いてみましたが、初回変更時のみの処理という点でも複雑ですし、スキーマに関するコードが散らばってしまい非直感的です。

また、validationの結果はformState.errorsで取れるのですが、上記のbyType.formatが必ず存在するフィールドではないため、errors.byType.formatが型エラーになってしまいます。

unknown

上記の問題を解決するために、discriminatedUnionの中では含まれるフィールドが共通になるようにします。
一方で片方で不要な値はunknown()を使って不定としておきます。

const someSchema = z.object({
  byType: z.discriminatedUnion('type', [
    z.object({
      type: z.literal('string'),
      value: requiredString,
      format: z.unknown()
    }),
    z.object({
      type: z.enum(['timestamp']),
      value: requiredString
      format: requiredString,
    }),
  ]),
})

初期化部分では以下のようにbyType.formatも空文字の初期値を入れておきます。

{
  byType: {
    type: "string",
    value: ""
    format: ""
  }
}

フィールドはunknownですが存在はするので型エラーは発生しません。
(一方で、format: 1のようになんでも入れられちゃうので、この辺は限界な部分な気がします)
同様にerrorsでもうまく行きます。
stringの場合はformatは不定なんだなということが初見でもコードから分かりやすいです。

prefixが汎用的になる場合

既存のフォームをリプレイスする作業をしていたため色々なフォームがあります。
例えば、複数の異なる画面から使われる共通のフォームを切り出したい場合です。
複数の異なる画面なのでZodのスキーマのパスが異なります。

const commonSchema = z.object({
  fuga: requiredString
})

const page1Schema = z.object({
  hoge: {
    common: commonSchema;
  }
})

const page2Schema = z.object({
  common: commonSchema;
})

namePrefixを渡せるコンポーネントを作りたかったのですが、これは困難でした。
無理矢理useFormContextのジェネリクスに[key: string]を渡すと一応InputControlの型チェックは通りましたが、errors部分は無理でした。

type DummyFormSchema = {
  [key: string]: { fuga: string }
}

type Props = {
  namePrefix: string
}

export const CommonForm: React.FC<Props> = ({
  namePrefix,
}) => {
  const {
    control,
    formState: { errors },
  } = useFormContext<DummyFormSchema>()
  return (
    <InputControl
      // ここは型エラーにならない
      name={`${namePrefix}.foo`}
      control={control}
      // ここが型エラーになる
      error={errors[namePrefix].foo}
    />
  )

この辺は解決できず、何かいい案があれば聞きたいところです。

GitHubで編集を提案

Discussion