🐣

React Hook Form、Zodで職務経歴書登録フォーム開発(リストフォーム編)

2023/10/12に公開

前回行ったこと

前回は React Hook Form、Zodで職務経歴書登録フォーム開発(バリデーション編) としてフォームのバリデーションを Zod で実装しました。今回は追加、削除可能な個数の増減のあるフォーム(ここでは リストフォーム と呼ぶこととする)を実装します。

今回使用したライブラリーのバージョン

  • Next.js: 13.4.19
  • React: 18.2.0
  • React Hook Form: 7.47.0
  • Zod: 3.22.4
  • MUI: 5.14.11
  • TailwindCSS: 3.3.3
  • Recoil: 0.7.7
  • TypeScript: 5.2.2
  • Firebase: 10.4.0

今回行ったことまとめ

  • 1人につき複数ある職務経歴をリストフォームで実装
  • バリデーションの強化(preprocessrefine を利用)

リストフォームの実装

リストフォームのイメージ

キャプチャーのような複数のフォーム。プラスボタンを押すとフォームが追加され、ごみ箱ボタンを押すとフォームが削除される。

利用するもの

React Hook Formの useFieldArray() を利用しました。
https://www.react-hook-form.com/api/usefieldarray/

useFieldArray() を利用するときのポイント

useForm() の型引数と zodResolver() の引数の型の変更が必要

useFieldArray() の引数にフォームの control を与えたいため、まずは useForm() を実行します。このとき useForm() の型引数はフォームの配列をフィールドとして保持する型にする必要があります。具体的には以下のコードの CareerFieldArray がそれに当たります。fieldArray というフィールドがあり、このフィールドに Career の配列を保持します。この fieldArray は別の名前でも問題ないですが、後ほど出てくるものと一致させる必要があります。

また、同様に zodResolver() の引数も CareerSchema の配列をフィールドに持つ CareerFieldArraySchema に変更します。

CareerList.tsx
const {
  formState: { errors, isDirty, dirtyFields },
  control,
  ...rest
} = useForm<CareerFieldArray>({
  mode: "onSubmit",
  reValidateMode: "onBlur",
  resolver: zodResolver(CareerFieldArraySchema),
});
types.ts
// これまでzodResolverの引数にはCareerSchemaを指定していたが、
// useFieldArrayを利用する場合はこのCareerFieldArraySchemaを指定する
export const CareerFieldArraySchema = z.object({
  fieldArray: CareerSchema.array(),
});

// これまでuseFormの型引数にはこのCareerを指定していた
export type Career = z.infer<typeof CareerSchema>;

// useFieldArrayを利用する場合はCareerFieldArrayを型引数として与える
export type CareerFieldArray = z.infer<typeof CareerFieldArraySchema>;

useFieldArray() の引数

useFieldArray() の引数として上記で取得した control を与えます。また、name フィールドには配列を持つフィールド名を与えます。これが CareerFieldArrayfieldArray になります。

CareerList.tsx
const { fields, append, remove, insert } = useFieldArray({
  control,
  name: "fieldArray",
});

各フォームの呼び出し方

今回各フォームは CareerForm というコンポーネントにしたので CareerList から CareerForm を呼び出しています。この際、errorsindex などを引数として渡しています。

CareerList.tsx
return (
  <Box className="w-2/3">
    <AddButton onClick={() => addForm(0)} />
    {fields.map((field, index) => {
      return (
        <Fragment key={`${field.id}_${index}`}>
          <CareerForm
            career={field}
            errors={errors}
            control={control}
            {...rest}
            index={index}
            loginUser={loginUser}
            deleteForm={deleteForm}
          />
          <AddButton onClick={() => addForm(index + 1)} />
        </Fragment>
      );
    })}
  </Box>
);

各フォームでフォームの値や状態を利用する方法

各フォームでフォームの値や状態を利用するには {...register(`fieldArray.${index}.projectName`)} とする必要があります。この fieldArrayCareerFieldArrayfieldArray と同じものです。index はリストの添え字です。

CareerForm.tsx
<TextField
  variant="outlined"
  label="Project name"
  required
  {...register(`fieldArray.${index}.projectName`)}
  error={!!errors.fieldArray?.[index]?.projectName}
  helperText={
    errors.fieldArray?.[index]?.projectName?.message as ReactNode
  }
  disabled={!watch(`fieldArray.${index}.isEditing`)}
/>

バリデーション強化

preprocess() を利用しバリデーション前に数値に変換する

teamSizeは次のように type="number" とはしていますが、実際に入力された値は文字列として扱われます。

CareerForm.tsx
<TextField
  type="number"
  variant="outlined"
  label="Team size"
  {...register(`fieldArray.${index}.teamSize`)}
  error={!!errors.fieldArray?.[index]?.teamSize}
  helperText={
    errors.fieldArray?.[index]?.teamSize?.message as ReactNode
  }
  disabled={!watch(`fieldArray.${index}.isEditing`)}
/>

しかし、Zodのスキーマは以下のようにNumber型としていたためうまくバリデーションできませんでした。

types.ts (変更前)
teamSize: z.number().int().positive(),

そこで今回以下のようにpreprocessを利用し事前にNumber型に変換し、その後にバリデーションが動くようにしました(ついでにnullも許容するようにしました)。

types.ts (変更後)
teamSize: z.union([
  z.null(),
  z.preprocess((value) => Number(value), z.number().int().positive()),
]),

実装後に知りましたが、今回のような単純な変換であれば React Hook Form で Zod を使う時の 5 つパターン に書かれている coerce を利用した方がよかったかもしれません。

refine() を利用し相関チェックする

今回、職務経歴の中の現職フラグ(isPresent)とプロジェクト離脱年月(endYearMonth)はどちらか一方のみ入力可能としました。これには2つのフィールドを参照する必要があるため、フィールドに対してバリデーションを定義するのではなく、object に対してバリデーションを定義する必要があります。具体的には次のように object() に対してメソッドチェーンを繋げ refine() を記述しました。また、第2引数のオブジェクトの path も重要です。これに ["endYearMonth"] を設定することで、errors.fieldArray?.[index]?.endYearMonth にこのメッセージが設定されるようになります。

types.ts
export const CareerSchema = z
  .object({
    // 省略
  })
  .refine(
    (args) => {
      return (
        (args.isPresent && !args.endYearMonth) ||
        (!args.isPresent && args.endYearMonth)
      );
    },
    {
      path: ["endYearMonth"],
      message: "Only one for isPresent and endYearMonth can be entered.",
    }
  );

コード

https://github.com/shoji9x9/firebase-nextjs

アプリケーション

https://nextjs-a609c.web.app/

次回予定

既に以下の対応は済んでいますが記事が長くなってしまうので、これらは次回の記事に回します。

  • タブの実装
  • MUIのDatePickerのクリア時のバグらしきもの

Discussion