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