React Hook Form、Zodで職務経歴書登録フォーム開発(リストフォーム編)
前回行ったこと
前回は 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人につき複数ある職務経歴をリストフォームで実装
- バリデーションの強化(
preprocessやrefineを利用)
リストフォームの実装
リストフォームのイメージ
キャプチャーのような複数のフォーム。プラスボタンを押すとフォームが追加され、ごみ箱ボタンを押すとフォームが削除される。

利用するもの
React Hook Formの useFieldArray() を利用しました。
useFieldArray() を利用するときのポイント
useForm() の型引数と zodResolver() の引数の型の変更が必要
useFieldArray() の引数にフォームの control を与えたいため、まずは useForm() を実行します。このとき useForm() の型引数はフォームの配列をフィールドとして保持する型にする必要があります。具体的には以下のコードの CareerFieldArray がそれに当たります。fieldArray というフィールドがあり、このフィールドに Career の配列を保持します。この fieldArray は別の名前でも問題ないですが、後ほど出てくるものと一致させる必要があります。
また、同様に zodResolver() の引数も CareerSchema の配列をフィールドに持つ CareerFieldArraySchema に変更します。
const {
formState: { errors, isDirty, dirtyFields },
control,
...rest
} = useForm<CareerFieldArray>({
mode: "onSubmit",
reValidateMode: "onBlur",
resolver: zodResolver(CareerFieldArraySchema),
});
// これまで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 フィールドには配列を持つフィールド名を与えます。これが CareerFieldArray の fieldArray になります。
const { fields, append, remove, insert } = useFieldArray({
control,
name: "fieldArray",
});
各フォームの呼び出し方
今回各フォームは CareerForm というコンポーネントにしたので CareerList から CareerForm を呼び出しています。この際、errors や index などを引数として渡しています。
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`)} とする必要があります。この fieldArray は CareerFieldArray の fieldArray と同じものです。index はリストの添え字です。
<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" とはしていますが、実際に入力された値は文字列として扱われます。
<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型としていたためうまくバリデーションできませんでした。
teamSize: z.number().int().positive(),
そこで今回以下のようにpreprocessを利用し事前にNumber型に変換し、その後にバリデーションが動くようにしました(ついでにnullも許容するようにしました)。
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 にこのメッセージが設定されるようになります。
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.",
}
);
コード
アプリケーション
次回予定
既に以下の対応は済んでいますが記事が長くなってしまうので、これらは次回の記事に回します。
- タブの実装
- MUIのDatePickerのクリア時のバグらしきもの
Discussion