🦀

React Hook FormのuseFormContext, zodを使用して動的にschemaを変更するステップフォームを作成

2024/11/30に公開

はじめに

  • 動的にschemaを変えたい
  • ステップフォームで入力したい
  • 入力フォームの内容が条件により異なる

上記の少し複雑な入力フォームを作成する際に苦戦し、結果としてReact Hook FormのuseFormContextを使用することで期待通りの動作をするようにできたのでわかる範囲でまとめてみました。

<作成するフォームの条件>

  • ステップフォームで2ページ目の登録するボタンを押下した際にsubmitする。
    • バリデーションは1ページずつ発火させる。
  • バリデーションは大まかに2パターンあり、1ページ目の入力フォームの選択によって2ページ目の入力フォームのバリデーションのパターンが変わる。
    • (A-a | A-b | B-c | B-d) or (C | D-e | D-f)となる。
  • バックエンドへpostする値はネストして送る値もある。下記のようなイメージ。
{
    contract_date: 'Y-m-d',
    member: {
        name: 'tarou',
        phone: '09088887777'
    }
}

当初React Hook FormのuseFormregisterを使用していましたが、ネストしている値がバリデーションエラーの際に表示されないことや、子コンポーネントへの型定義が手間なことが問題としてありました。

環境

ライブラリなど バージョン
React 18.3.1
TypeScript 5.4.3
Inertia.js 1.2.0
ReactHookForm 7.52.0
zod 3.23.8

useFormContextとは

React Context API for hook formとあるようにReact Context APIを使っているようで自分が認識しているメリットとしては下記になります。
子コンポーネントに対してprops drilling防止になりシンプルになる。
また該当画面のフォームの内容だけに集中できるので、複雑な要件でも少し責務が分離できる認識です。

実装

前提

バリデーションに関して(A-a | A-b | B-c | B-d) or (C | D-e | D-f)なのですが、イメージとして下記のような状態になります。

例:コワーキングスペースで個人で契約する場合とオフィスとして契約する場合のschema
コワーキングスペースのエリアは下記の3タイプ存在します。

  • 個人契約でしか契約できないエリア
  • オフィスでしか契約できないエリア
  • オフィスでも個人契約でもできるエリア

契約タイプとエリアの種別によってschemaも変わりますし、画面の見た目も変わります。見た目、そして動作のキーとなる条件が二つあることになります。

<個人契約の場合>

  • 1つ目の入力フォームで入力する内容(A)
    • 使用開始日
    • 選択したエリアがオフィスエリアだった場合は、オフィスエリアの契約があった場合はエリアが移動になるor解約になる場合があることに同意するチェック(A-a | A-b)
  • 2つ目の入力フォームで入力する内容(C)
    • 氏名、住所
    • 注意事項に確認しましたのチェック

<オフィス契約の場合>

  • 1つ目の入力フォームで入力する内容(B)
    • 使用開始日
    • 他の使用者が決まっている場合(B-c)
      • 何人かの情報
    • 他の使用者が決まっていない場合(B-d)
      • 未定のチェック
  • 2つ目の入力フォームで入力する内容(D)
    • 氏名、住所
    • 会社名、情報
    • 注意事項に確認しましたのチェック
    • 他の使用者が決まっている場合(D-e)
      • 一緒に働く人の氏名や連絡先など
    • 他の使用者が決まっていない場合(D-f)
      • 追加入力項目なし

zodのdiscriminatedUnionでAorBのようなschemaを設定可能ですが今回のような条件の場合、duplicate errorが出てしまいました。
またunionの場合はエラーが出ませんが入力値にエラーがあった場合にエラーと判定されていないようでした。
zodのdiscussionsに似たような内容の書き込みがありましたが、そのような複雑なschemaの設定はできないのではないかなと思いschemaは他の方法で対応することにしました。

動的なschemaの変更に関して

inertia.jsを使用していたのですが、inertia.jsにuseRememberという機能があり、Historyを使用して入力した情報をブラウザの履歴に保存し後から復元することができるようです。
このuseRememberの機能はinertia.jsのuseFormに含まれています。

また、inertia.jsのrouter機能でPOSTする場合は自動的にpreserveState: trueとなっているため、コンポーネントの状態を維持するようになっており、入力した情報を保存し続けることができるようになっているようでした。

そのため親コンポーネントの中で下記のようにすることでstep formで動的にschemaを入れ替えることができます。

import { useForm as inertiaForm } from '@inertiajs/react'
import { useForm } from 'react-hook-form'
// 略

const { data, setData } = inertiaForm<FormSchemaType>(
    `${uuid}.contractCreate`,
    defaultValues()
)

const getSchema = useCallback(() => {
    if (step === STEP.FIRST_FORM) {
        // 個人契約の場合はエリアでschemaを変える
        if (isPersonalContract) {
            return isOfficeArea ? A-aのschema : A-bのschema
        }
        // オフィス契約の場合は他の使用者が未定かでschemaを変える
        return data.member.is_undecided
          ? B-dのschema
          : B-cのschema(space.number_of_people) // エリアに契約可能な人数を引数へ
    }

    return isPersonalContract ? Cのschema : Dのschema
}, [step, isPersonalContract, isOfficeArea, data, space])

const methods = useForm<FormSchemaType>({
    mode: 'onChange',
    resolver: zodResolver(getSchema()),
    defaultValues: data
})

各入力フォームでschemaを変える場合にinertiaFormのsetDataにReactHookFormの入力内容をすべて渡してschemaを変更できるようにしています。

schemaに関して

前項にあるようにschemaを6つ用意します。
requiredSelectrequiredBooleanなどなどは独自に作成した必須のschemaです。

// A-aのschema
export const PersonalContractOfficeAreaFirstSchema = z.object({
    contract_date: requiredSelect(),
    is_personal_contract: z.boolean(),
    agreement_to_forced_termination: requiredBoolean()
})

// A-bのschema
export const PersonalContractFirstSchema = z.object({
    contract_date: requiredSelect(),
    is_personal_contract: z.boolean(),
    agreement_to_forced_termination: z.boolean() 
})

// B-cのschema
export const createOfficeContractFirstSchema = (number: number) => z.object({
    contract_date: requiredSelect(),
    is_personal_contract: z.boolean(),
    agreement_to_forced_termination: z.boolean(),
    member: z.object({
        is_undecided: z.literal(false),
        number_of_people: dynamicNumberOfPeople(number) // 契約するエリアに契約可能な人数がある場合に契約可能な人数以上を入力した場合にエラーを表示
    })
})

// B-dのschema
export const OfficeContractFirstSchema = z.object({
    contract_date: requiredSelect(),
    is_personal_contract: z.boolean(),
    agreement_to_forced_termination: z.boolean(),
    member: z.object({
        is_undecided: z.literal(true),
        number_of_people: z.string()
    })
})

// Cのschema
export const PersonalContractSecondSchema = z.object({
    contractor: z.object({
        name: requiredString(),
        phone: requiredPhone(),
    }),
    agreement_to_regulation: requiredBoolean(),
    member: z.object({
        is_undecided: z.boolean(),
        number_of_people: z.string()
        name: z.string(),
        phone: z.string(),
    })
})

// D-e, D-fのschema
export const officeContractSecondSchema = z.object({
    contractor: z.object({
        name: requiredString(),
        phone: requiredString(),
    }),
    member: z.discriminatedUnion('is_undecided', [
        z.object({
            is_undecided: z.literal(true),
            name: z.string(),
            phone: z.string(),
        }),
        z.object({
            is_undecided: z.literal(false),
            name: requiredString(),
            phone: requiredString(),
        }),
    ]),
    agreement_to_regulation: requiredBoolean()
})

export type FormSchemaType =
    | z.infer<(typeof PersonalContractOfficeAreaFirstSchema | typeof PersonalContractFirstSchema)> & typeof PersonalContractSecondSchema
    | z.infer<(ReturnType<typeof createOfficeContractFirstSchema> | typeof OfficeContractFirstSchema)> & typeof officeContractSecondSchema

export const defaultValues = (): FormSchemaType => {
    // 個人で契約orオフィス契約に関わらずすべてのキーを返す
}

コンポーネントに関して

あとは親コンポーネントでドキュメント通りuseFormContextを使用してstepFormを囲むようにします。

親コンポーネント

<FormProvider {...methods}>
    {step === STEP.FIRST && (
        <>
            <CommonInfomation />
            {isPersonalContract ? (
                <PersonalContractFirstForm
                    isOfficeArea={isOfficeArea}
                    setInertiaFormData={setData}
                />
            ) : (
                <OfficeContractFirstForm
                    setInertiaFormData={setData}
                />
            )}
        </>
    )}

    {step === STEP.SECOND && (
        // 略
    )}
</FormProvider>

const { register, setValue, getValues, handleSubmit } = useFormContext<FormSchemaType>()

return (
    <form onSubmit={handleSubmit(onNext)}>
        // 入力フォーム
        <button type="submit">次へ</button>
    </form>
)


今回の実装で初めてuseFormContextを知ったので面白いな〜と思いました。
動的にschemaを変えるのにinertia.jsを使用していますが、この部分に関してはinertia.jsを使用せずとも再現可能ではないかな〜と思っています。この案件では元々inertia.jsとPHP + laravelを使用していたためinertia.jsで実装しています。

もしかしたらもっといい方法があるのかもしれませんが、なんとか意図した動作になったので良かったです(っ`ᵕ´c)

Discussion