🦀

React Hook Form, Zod¦配列のフォームで重複があった場合にエラー表示する

2024/12/23に公開

はじめに

  • 動的に入力フォームが増やせる
  • 日付、時間、担当者、タイプなどの入力項目があり同一の日付、時間、担当者の入力をエラー表示にする

上記の入力フォームを作成したのでわかる範囲でまとめてみました🕺

環境

ライブラリなど バージョン
React 18.3.1
TypeScript 5.4.3
ReactHookForm 7.53.2
Zod 3.23.8

まとめ

  • 入力フォーム
    • ReactHookFormのuseFieldArrayを使用する
    • 各入力フォームのコンポーネントはReactHookFormのControllerで作成する
  • バリデーション
    • ZodのsuperRefineを使用
    • superRefineのpathとReactHookFormのuseFieldArrayで設定するnameが一致させる

結論としては、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を使用したセレクトボックスの場合

SelectForm.tsx
<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