React Hook Form, Zod¦配列のフォームで重複があった場合にエラー表示する
はじめに
- 動的に入力フォームが増やせる
- 日付、時間、担当者、タイプなどの入力項目があり同一の日付、時間、担当者の入力をエラー表示にする
上記の入力フォームを作成したのでわかる範囲でまとめてみました🕺
環境
ライブラリなど | バージョン |
---|---|
React | 18.3.1 |
TypeScript | 5.4.3 |
ReactHookForm | 7.53.2 |
Zod | 3.23.8 |
まとめ
- 入力フォーム
- ReactHookFormの
useFieldArray
を使用する - 各入力フォームのコンポーネントはReactHookFormのControllerで作成する
- ReactHookFormの
- バリデーション
- Zodの
superRefine
を使用 -
superRefine
のpathとReactHookFormのuseFieldArray
で設定するnameが一致させる
- Zodの
結論としては、ZodのsuperRefine
を使用した際のpathとReactHookFormのuseFieldArray
で設定するnameが一致するようにすれば、ControllerコンポーネントのfieldState.error.message
で該当項目のエラーを表示できるようです。
詳細
入力フォーム
増える入力フォームと、増えない入力フォームの値をまとめてサーバーへpostする想定です。
- 増えない入力フォーム。入力フォームがひとつだけのもの。
- ユーザー(user_id)
- 増える入力フォーム。ボタンを押下すると下記が一括で増えます。
- 日付(date)
- 時間(start_time)
- 担当者(admin_id)
- タイプ(type)
細かい実装に関しては省きますが、入力フォームはReactHookFormのController
を使用しており、概ね下記のような実装になっています。
エラー時のメッセージはfieldState.error.message
で表示しています。
radix-uiを使用したセレクトボックスの場合
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<>
<Select
onValueChange={value =>
field.onChange(isTypeNumber ? Number(value) : value)
}
defaultValue={field.value ? String(field.value) : undefined}
disabled={disabled}
>
<SelectTrigger className={cn(fieldState?.error && 'red')}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map(v => (
<SelectItem key={v.value} value={String(v.value)}>
{v.label}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState?.error && <p>{fieldState.error.message}</p>}
</>
)}
/>
ReactHookFormのuseFieldArray
を使用して増やせる入力フォームを作成します。
今回は増やすだけで、減らすボタンは作成しなかったためappend
のみ使用します。
また増えるフォームの初期値のdefaultValueを複数箇所で使用するため定数で作成します。
const INITIAL_INVITATIONS = {
date: '',
start_time: '',
admin_id: '',
type: ''
}
const form = useForm({
mode: 'onChange',
resolver: zodResolver(schema),
defaultValues
})
const { fields, append } = useFieldArray({
control: form.control,
name: 'invitations'
})
return (
<form>
<InputForm name='user_id' control={control} />
{fields.map((field, index) => (
<div key={field.id}>
<button onClick={() => append(INITIAL_INVITATIONS)}>追加</button>
<div>
<SelectForm
name={`invitations.${index}.admin_id`}
control={control}
options={adminOptions} // 選択肢
/>
<SelectForm
name={`invitations.${index}.type`}
control={control}
options={typeOptions}
/>
<DatePickerForm
name={`invitations.${index}.date`}
control={control}
/>
<SelectForm
name={`invitations.${index}.start_time`}
control={control}
options={timeOptions}
/>
</div>
</div>
))}
</form>
)
zodに関して
defaultValues
入力フォームは以下のようになっています。
- 増減しない入力値
- ユーザー(user_id)
- 増減する入力値。ボタンを押下すると下記が一括で増えます。
- 日付(date)
- 時間(start_time)
- 担当者(admin_id)
- タイプ(type)
また入力フォームで増える入力フォームをinvitations.${index}.カラム名
のようにしていたのでinvitations
を下記のようにします。
const defaultValues = {
user_id: '',
invitations: [INITIAL_INVITATIONS]
}
schema
増える入力フォームinvitations
の入力をすべて必須で作成します。
重複判定する関数で型のエラーが出るため、増える入力フォームだけのschemaから型を作成して重複判定する中で使用します。
// requiredSelectは独自に作成した選択必須のschema
const invitationsSchema = z.object({
date: requiredSelect('日付'),
start_time: requiredSelect('開始時間'),
type: requiredSelect('ミーティングの種類'),
admin_id: requiredSelect('担当者'),
})
type InvitationSchema = z.infer<typeof invitationSchema>
const schema = z.object({
user_id: requiredSelect('対象者'),
invitations: invitationSchema.array().nonempty()
})
.superRefine((val, ctx) => {
// 略
})
重複判定はsuperRefine
内で行います。
superRefineの内容
同一の日付、時間、担当者の場合はエラーとしますが、すべて入力された時に判定するようにしないと他の項目が未選択や未入力の段階でエラー表示されるてしまうため未入力の場合は判定しないようにします。
if (
val.invitations.some(
v =>
v.date.length === 0 ||
v.start_time.length === 0 ||
v.admin_id.length === 0
)
) {
return
}
すべて入力済みの場合は空の配列を作成し、その配列に比較する値を入れていきfindIndexで重複を発見した時は入力フォームのnameに合うようにpathを設定しエラーを出すようにします。
ここで元の値(val)を加工するとform.handleSubmitするときの値にも影響が出るので注意です。
const compareItems: InvitationSchema[] = []
val.invitations.map((invitation, index) => {
if (index === 0) {
compareItems.push({
date: invitation.date,
start_time: invitation.start_time,
type: invitation.type,
admin_id: invitation.admin_id
})
return
}
const errorIndex = compareItems.findIndex(v => {
return (
v.date === invitation.date &&
v.start_time === invitation.start_time &&
v.admin_id === invitation.admin_id
)
})
if (errorIndex !== -1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '日付、時間、担当者が重複した予約があります。',
path: [`invitations.${errorIndex}.admin_id`],
})
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '日付、時間、担当者が重複した予約があります。',
path: [`invitations.${errorIndex}.date`],
})
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '日付、時間、担当者が重複した予約があります。',
path: [`invitations.${errorIndex}.start_time`],
})
}
compareItems.push({
date: invitation.date,
start_time: invitation.start_time,
type: invitation.type,
admin_id: invitation.admin_id,
})
})
これで意図した通りに動くといえば動きますが
バリデーションエラー後に入力欄を修正した際に再度判定させないとエラー表示が消えないので、その辺りのブラッシュアップをする必要はあります😾💭
重複判定でもっと良い実装方法がありそうなのですが今後見つけていけたらいいなと思っています💭
Discussion