バイセル Tech Blog

バイセル Tech Blogは株式会社BuySell Technologiesのエンジニア達が知見・発見を共有する技術ブログです。

バイセル Tech Blog

react-hook-formとZodで作る型が動的なフォーム

はじめに

こんにちは! 開発 2 部の小林です。

弊社の各プロジェクトでは、React Hook Form (以後 RHF とする) と Zod を用いて、様々な要件のフォームの実装をしています。

今回は、API 側から取得した値に基づいて、text, number, checkbox のように、入力項目を出し分けられる型が動的なフォーム(以後 動的フォーム とする)を作成しました。

この記事では、実装した動的フォームのコード例と、実装の過程で直面した課題+その解決策について紹介します。

対象読者

本記事は以下のエンジニアを対象にしています。

  • RHF × Zod の構成でフォームを実装したことがあるエンジニア
  • 上記の構成で動的フォームを作成したいエンジニア

もし、RHF × Zod の組み合わせに馴染みのない方は、本記事を読む前に、各ライブラリの公式ドキュメントや以下の記事を読むことをお勧めいたします。

実務で使った React-Hook-Form × Zod の事例紹介

React Hook Form + Zodにおける型不整合の解消

Zod の schema で動的なフォームを表す方法

まず初めに、今回の記事で一番重要なポイントである、Zod の schema で動的フォームを表す方法を紹介します。

もし本章がピンとこなかった場合は、対象読者に記載の URL から前記事を読まれることをお勧めいたします。

動的フォームに通常の Zod schema を使用できない理由

通常の Zod schema では、RHF で管理する key と value が、フォーム作成時に決定されていることが必要なため、今回作成する動的フォームでは使用できません。

// usernameはstring、ageはnumberとschema定義の段階で決定しないといけない
const formSchema = z.object({
  username: z.string(),
  age: z.number(),
})

動的フォームでは、API 側からの値によって動的に schema を設定できる必要があります。

例えば、number の時は number 型を適用し、checkbox の時は boolean 型を適用するように、フォームの入力値に対して決まった型を適用できる必要があります。

動的に schema を設定できる descriminated union

そこで、今回上記の問題を解決するために使用したのが、Zod の descriminated union という機能です。

https://github.com/colinhacks/zod#discriminated-unions

descriminated union を使用すれば、指定した値に基づいて、適用する schema を動的に設定できるようになります。

例えば、以下公式の例では、statusの値に基づいて、適用する schema を動的に設定しています。

const myUnion = z.discriminatedUnion('status', [
  z.object({ status: z.literal('success'), data: z.string() }),
  z.object({ status: z.literal('failed'), error: z.instanceof(Error) }),
])

// statusがsuccessの場合は、z.object({ status: z.literal("success"), data: z.string() })が適用される
myUnion.parse({ status: 'success', data: 'yippie ki yay' })

次の章では、実際にこの descriminated union を使って動的フォームを作成していきます。

作成した動的フォームの説明

デモについてはこちらから確認できます。

要件

今回作成した動的フォームの要件は、API 側からフォームの情報を取得して、フロント側で各入力項目(以後フォームの入力項目ごとのまとまりを指して fieldset とする)にマッピングできるようにすることです。

具体的には、以下のようなフォームの情報を含むデータを API から受け取り、各 fieldset にマッピングし、添付画像のようなフォームを作成していきます。

const API_MOCKDATA = [
  {
    id: 1,
    type: 'TEXT',
    payload: {
      name: 'username',
      label: 'ユーザー名',
    },
  },
  {
    id: 2,
    type: 'TEXTAREA',
    payload: {
      name: 'memo',
      label: '備考',
    },
  },
  {
    id: 3,
    type: 'NUMBER',
    payload: {
      name: 'age',
      label: '年齢',
    },
  },
  {
    id: 4,
    type: 'SELECTBOX',
    payload: {
      name: 'country',
      label: '国籍',
      options: [
        {
          value: 'japan',
          label: '日本',
        },
        {
          value: 'usa',
          label: 'アメリカ',
        },
        {
          value: 'uk',
          label: 'イギリス',
        },
      ],
    },
  },
  {
    id: 5,
    type: 'SINGLE_CHECKBOX',
    payload: {
      name: 'firstvisit',
      label: '初来訪',
    },
  },
]

設計

今回各コンポーネントは以下の添付画像のように設計しました。

コンポーネント名 役割
Form 外部から取得してデータの整形・初期値の設定・useForm の設定・submit 処理
FieldsetFactory 整形後の外部データを各 Filedset にマッピングする
Fieldset 各入力項目の実態を定義・各パーツと Form を接続する

以降の具体的なコード例の章では、設計資料のどこを作成しているのか確認していきながらコードを追っていきます。

具体的なコード例

Schema

まず初めに、RHF で使用する型を生成する必要があるため、Schema から定義していきます。

export const fieldsetSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('TEXT'), payload: z.string() }),
  z.object({ type: z.literal('TEXTAREA'), payload: z.string() }),
  z.object({ type: z.literal('NUMBER'), payload: z.number().nullable() }),
  z.object({ type: z.literal('SELECT'), payload: z.string() }),
  z.object({ type: z.literal('SINGLE_CHECKBOX'), payload: z.boolean() }),
])

const formSchema = z.record(fieldsetSchema)

type InputSchema = z.input<typeof formSchema>

export type FormSchema<
  T extends InputSchema[string]['type'] = InputSchema[string]['type'],
  U extends InputSchema[string] = InputSchema[string]
> = Record<string, Extract<U, { type: T }>>

export const resolver = zodResolver(formSchema)

前述の descriminated union を Record 型とすることで RHF と Zod を連携するための schema を作成できます。

今回の動的フォームは、API 側のデータからフォームを作成することから、key / name を事前に想定して作ることはできないため、RHF で管理する値の型が index signature となります。

課題の章で触れますが、上記理由から、RHF で管理している値の型において、 key と name に対して 1 対 1 の対応ができなくなるため、推論が効かなかったり、値を使用する場合に union 型になってしまう等の弊害があります。

Form

このコンポーネントでは、Form の設定と、API から取得してきた値を整形し、FieldsetFactory にわたす役割を担います。

const API_MOCKDATA: Array<FieldsetFactoryProps & { id: number }> = [
  // ...省略
];

const getInitialValue = (
  type: FieldsetFactoryProps["type"]
): FormSchema[string] => {
  // ...省略
};

export const Form = () => {
  const initialValue = API_MOCKDATA.reduce<FormSchema>(
    (acc, cur) => ({
      ...acc,
      [cur.payload.name]: getInitialValue(cur.type)
    }),
    {}
  );
  const methods = useForm<FormSchema>({
    resolver,
    defaultValues: initialValue
  });

  return (
    <FormProvider {...methods}>
      <form
        onSubmit={methods.handleSubmit(
          (data) => console.log("success", data),
          (data) => {
            console.log("value", methods.getValues());
            console.log("error", data);
          }
        )}
      >
        {API_MOCKDATA.map((props) => (
          <FieldsetFactory key={props.id} {...props} />
        ))}
        <button type="submit">submit</button>
      </form>
    </FormProvider>
  );
};

今回のデモでは、整形済みの API_MOCKDATA を用意して作成していますが、通常ではこの層で API から得た値を使って、初期値の設定・FieldsetFactory コンポーネントに渡す各値を作成します。
また、submit 処理もこのコンポーネントで行います。
transform を使用して、schema 内に変換処理を委譲するのも良いかもしれません。

  formSchema
  .transform((val) => {
    return Object.entries(val).map(([key, value]) => {
      // keyはreact-hook-formで管理しているnameと同じ値になる(ex: username)
      // valueはdescriminated unionで指定した値のどれかになる(ex: { type: "TEXT", payload: "テスト 太郎" })
      switch (value.type) {
        case "TEXT":
          return {
            value: value.payload,
            valueType: "STRING",
          };
        case "TEXTAREA":
          return {
            value: value.payload,
            valueType: "STRING",
          };
        // ...省略
      }
  });

FieldsetFactory

このコンポーネントでは、API 側から取得・整形した値を各 Fieldset コンポーネントへ、マッピングを行います。 switch 文を使って type の値を一意に特定し、対応する payload 型を特定します。

export type FieldsetFactoryProps =
  | {
      type: 'TEXT'
      payload: TextFieldSetProps
    }
  | {
      type: 'TEXTAREA'
      payload: TextareaFieldsetProps
    }
  | {
      type: 'NUMBER'
      payload: NumberFieldsetProps
    }
  | {
      type: 'SELECTBOX'
      payload: SelectFieldSetProps
    }
  | {
      type: 'SINGLE_CHECKBOX'
      payload: SingleCheckboxFieldSetProps
    }

export const FieldsetFactory: React.FC<FieldsetFactoryProps> = (props) => {
  switch (props.type) {
    case 'TEXT':
      return <TextFieldSet {...props.payload} />
    case 'TEXTAREA':
      return <TextareaFieldset {...props.payload} />
    case 'NUMBER':
      return <NumberFieldset {...props.payload} />
    case 'SELECTBOX':
      return <SelectFieldSet {...props.payload} />
    case 'SINGLE_CHECKBOX':
      return <SingleCheckboxFieldSet {...props.payload} />
    default:
      return null
  }
}

Fieldset

このコンポーネントでは、各フォームパーツの実態を定義しています。 ここでは NumberFieldset を抜粋して紹介します。

export type NumberFieldsetProps = BaseFieldSet

export const NumberFieldset: React.FC<NumberFieldsetProps> = ({
  name,
  label,
}) => {
  const { control } = useFormContext<FormSchema<'NUMBER'>>()
  const { field } = useController({ control, name })

  const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
    const value = parseInt(e.target.value, 10)
    field.onChange({
      type: 'NUMBER',
      payload: Number.isNaN(value) ? null : value,
    })
  }

  return (
    <fieldset>
      <legend>{label}</legend>
      <input
        type="number"
        value={field.value?.payload ?? ''}
        onChange={handleChange}
      />
    </fieldset>
  )
}

RHF の useFormContext を使用して、Context 経由で関連値を参照するようにし、パフォーマンスの最適化を行なっています。

RHF のパフォーマンスに関しては、この記事の対象外なので、詳しくは以下スライドをご参照ください。

React Hook Form はどのように再レンダリングを最適化しているのか?

ここで重要な点は、useController・field.onChange を使って、値を更新しているため、controlled form となっている点です。
こちらも課題の章で触れますが、descriminated union を使っているため、値に schema を特定するための値(今回は type) を含める必要があるため、必ず controlled form にする必要があります。

const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
  field.onChange({
    type: 'NUMBER',
    payload: parseInt(e.target.value, 10),
  })
}

controlled form と uncontrolled form の違いについては、React の公式に記載があるのでそちらをご参照ください。

https://reactjs.org/docs/uncontrolled-components.html

https://reactjs.org/docs/forms.html

実装過程で直面した課題

必ず Controlled form にする必要があるためパフォーマンスが悪い

問題概要

動的に型を適用するために、descriminated union を使っているため、一意に schema を特定できる値を RHF で管理する値に含める必要があります。

今回はtypeを含めた値で value を更新する必要があり、RHF の register を用いた uncontrolled form では実現できません。

具体的には以下にのように、各 Fieldset コンポーネントで、useController & field.onChange を使って固定値の type を含めた値で更新しています。

const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
  field.onChange({
    type: 'NUMBER',
    payload: parseInt(e.target.value, 10),
  })
}

解決策

この問題に関しては、RHF の仕様上しょうがなく、根本解決の方法がないと判断しました。
そのため、controlled form になること自体は許容し、useWatch・useFormContext 等を用いて、更新時に再レンダリングされるコンポーネントを最小限にすることで対応しています。

Fieldset コンポーネントに渡す value が union 型になってしまう

問題概要

動的フォームでは、schema 定義の際に特定の key を想定して作成することができないため、以下のような index signature になります。

// 動的フォーム
// keyとvalueが1対1になっていない
// RHFのnameに何の文字列を設定しても、値がunion型として推論される
type InputSchema = {
    [x: string]: {
        type: "TEXT";
        payload: string;
    } | {
        type: "TEXTAREA";
        payload: string;
    } | {
        type: "NUMBER";
        payload: number | null;
    } | // ...省略

// 静的フォーム
// keyとvalueが1対1になっている
// RHFのnameにusernameを設定すると、紐づく値の型が推論される
type InputSchema = {
  username: {
    type: "TEXT";
    payload: string;
  },
  age: {
        type: "NUMBER";
        payload: number;
  }
}

そのため、key と value の 1 対 1 の対応が崩れてしまい、Fieldset コンポーネントで name 属性から value を取得しようとした時に union 型になってしまいます。

export type NumberFieldsetProps = BaseFieldSet;

export const NumberFieldset: React.FC<NumberFieldsetProps> = ({
  name,
  label
}) => {
  const { control } = useFormContext<InputSchema>();
  const { field } = useController({ control, name }); // nameはstring型なので、filed.valueはunion型になる

  // ...省略

  return (
    <fieldset>
      <legend>{label}</legend>
      <input
        type="number"
        value={field.value?.payload} // string | number | boolean | null となってしまう
        onChange={handleChange}
      />
    </fieldset>
  );
};

解決策

カスタム型を作って、value の型を特定するようにしました。 Extract を使って、T で受け取ったtypeの値によって、union 型を一意に特定し、型付けを行なっています。

// schema/formSchema.ts
export type FormSchema<
  T extends InputSchema[string]["type"] = InputSchema[string][type],
  U extends InputSchema[string] = InputSchema[string]
> = Record<string, Extract<U, { type: T }>>;

// Fieldset.tsx
export const NumberFieldset: React.FC<NumberFieldsetProps> = ({
  name,
  label
}) => {
  const { control } = useFormContext<FormSchema<"NUMBER">>() // ここでFormSchema適用する。descriminated unionで定義しているtypeのどれかを受け取ることにしているので、推論が働く。
  const { field } = useController({ control, name });

  // ...省略

  return (
    <fieldset>
      <legend>{label}</legend>
      <input
        type="number"
        value={field.value?.payload} // numberになる
        onChange={handleChange}
      />
    </fieldset>
  );
};

FormSchema はTとして、type を受け取るようにしており、FromSchema の第 1 引数で文字列を入力しようとすると、type で指定可能な中から推論されるようになっています。

また、第 1 引数の default 値に type 型の union を指定しているので、引数を省略するとそのまま union 型としても使用できます。
以下では、useForm に引数なしで FormSchema を適用することで、InputSchema と同様の型として使えています。

// Form.tsx
const methods = useForm<FormSchema>({
  resolver,
  defaultValues: initialValue,
})

上記の使い方から、InputSchema の export をやめて、FormSchema の使用を強制することができます。

まとめ

RHF × Zod の構成で、動的フォームを作成できました。descriminated union を使えば、API や入力値によって、フォームの内容が動的に変わる要件に対応できます。

さらに上記に合わせて、RHF の useFieldArray を使うことで、数すらも動的で複雑な GoogleForm のようなフォームを作成することもできます。
機会があれば、続編も書きたいと思いますのでお楽しみに!

BuySell Technologies ではエンジニアを募集しています。こういった取組に興味がある方はぜひご応募をお願いします!

herp.careers