
UPSIDERでエンジニアをしている太田です。 (@Hide55832241)
複数ステップに分かれたFormの実装は、一見簡単そうに見えて実は難しいことがあります。
例えば、以下のような課題に直面することがあります
- ステップ間の下書きと検証後の値の扱いの難しさ
- 画面遷移時の状態管理の複雑化
- 条件分岐による異なるフローの管理
- APIから返却されたエラーのハンドリング
本記事では、TypeScriptとReactを使用した実装における課題と解決方法を解説します。
なお今回は実装面に焦点を当て、設計思想については触れません。
前提と注記
- TypeScript + React環境
- コードの可読性を優先し、本来アルファベットを使うべき箇所に一部日本語を使用する箇所がある
- 外部ライブラリは使用しないコードを記載(実際の実装時には
zodやreact-hook-formを使用することが多い) - 混乱を避けるため記事内では文字列項目の空文字は、
""ではなくnullとして扱い、nullを許容しないstring型の項目は空文字を許容しないこととする
仕様
基本仕様
- 各ステップで入力値の検証 (フロントエンドの検証のみ)
- ステップ1の選択内容によってステップ2の入力項目が動的に変化
- ステップ間を移動しても入力値は保持される
- 確認画面で入力値をAPI送信
画面遷移仕様
基本フロー

戻る

- 各ステップから前のステップへ戻ることができる
Step1の選択肢によりStep2は分岐する

確認画面から編集

- 確認画面から編集後は確認画面へ遷移する
確認画面からStep1の編集

- 確認画面からStep1の編集後は基本フローと同様
編集画面で戻る

- 編集画面から戻ると変更内容を破棄して確認画面へ遷移する
全体構成
以下のディレクトリ構成とします。
簡潔に記載するためにすべてフラットに配置していますが、実際にはコンポーネントなどまとまりのある単位でネスト構造とすると思います。
└── MultiStepForm ├── Step1Form.tsx ├── Step2法人Form.tsx ├── Step2個人事業主Form.tsx ├── Step3Form.tsx ├── formStateReducer.ts └── MultiStepForm.tsx
ステップごとのFormの設計
ステップ毎にFormコンポーネントを作成します。
全体構成の Step1Form.tsx 、 Step2法人Form.tsx 、 Step2個人事業主Form.tsx 、 Step3Form.tsx がこれに当たります。
コンポーネントのPropsを以下のようにします。
defaultValues: Formを開いた際に設定される値onSubmit: Formを確定して次のステップへ進む関数onBack: 前のステップへ戻る関数(現在の入力値を保持)onCancel: 確認画面から遷移した際の編集をキャンセルする関数(変更を破棄)
この設計により、各ステップが独立したコンポーネントとして機能し、状態管理の責務を親コンポーネントに委譲できます。
これにより、複雑な状態遷移のロジックを一箇所に集約でき、テストやメンテナンスが容易になります。
入力型と出力型について
Formの実装をする際に入力型と出力型を明確に分けて定義しています。
入力型は検証前の入力値の型です。
たとえばわたしたちの一部のプロダクトでは、全角数字を入力したり、ペーストするユーザーを考慮し、数値項目であっても文字列で自由に入力できるようになっています。
それらを考慮すると半角のvalidな数値しか入力できなくするのではなく、自由な入力を受け入れ検証した後にvalidな数値としてnumber型で扱うようにしています。
また数値だけではありません。
入力値の型 = 出力値の型にしてしまうと、デフォルト値など検証後の型に一致しない値をFormにセットできなくなります。
必須項目だけどデフォルト値を空にしたいといったことができなくなります。
出力型をゆるくしたり、 as any を使って妥協しているプロダクトも多いのではないでしょうか。
この仕様は通常スキーマ定義する際に使用しているZodとReact Hook Formでもこれらが考慮されています。
Zodでは以下のように入力型、出力型がそれぞれ得られます。
const schema = z.object({ v: z.string().transform(v => Number(v)) }) z.input<typeof schema> // string z.output<typeof schema> // number
React Hook Formでは以下のように定義すると入力型と出力型がそれぞれ指定することができます。
// 入力型と出力型を指定 useForm<z.input<typeof schema>, unknown, z.output<typeof schema>>({ defaultValues: { v: '' } // OK }) // 入力型 = 出力型を指定 useForm<z.infer<typeof schema>>({ defaultValues: { v: '' } // Type 'string' is not assignable to type 'number' })
Step 1
type InputSchema = { organizationType: '法人' | '個人事業主' | null } type OutputSchema = { organizationType: '法人' | '個人事業主' } type FormProps = { defaultValues: InputSchema onSubmit: (v: OutputSchema) => void onCancel: () => void }
Step 2 (法人)
type InputSchema = { corporateNumber: string | null organizationName: string | null } type OutputSchema = { corporateNumber: string organizationName: string } type FormProps = { defaultValues: InputSchema onSubmit: (v: OutputSchema) => void onBack: (v: InputSchema) => void onCancel: () => void }
Step 2 (個人事業主)
type InputSchema = { organizationName: string | null name: string | null } type OutputSchema = { organizationName: string | null name: string } type FormProps = { defaultValues: InputSchema onSubmit: (v: OutputSchema) => void onBack: (v: InputSchema) => void onCancel: () => void }
Step 3
type InputSchema = { email: string | null } type OutputSchema = { email: string } type FormProps = { defaultValues: InputSchema onSubmit: (v: OutputSchema) => void onBack: (v: InputSchema) => void onCancel: () => void }
※ 注記
- 各Formの詳細な実装は単一Formの実装と同じ (APIとの結合はない代わりにonSubmitを実行する) になるため省略し、コンポーネントのPropsのみ記載しています
- 詳細な実装は下記の記事が参考になると思います
- 戻るボタンをFormコンポーネント内に定義すべきではないかもしれませんが、簡潔に説明するためFormコンポーネント内に定義します
各ステップで使用する共通の型
入力値 (検証前の値)
入力値は以下の理由からすべてのステップを通じて常に保持します。
Step2の動的な項目への対応
Step1の選択によってStep2の項目が変わりますが、 DraftValues では両方のパターンの値を保持する設計にしています。
理由
- 法人を選択 → Step2で法人情報を入力
- Step1に戻って個人事業主に変更 → Step2で個人情報を入力
- 再度Step1に戻って法人を選択
- Step2に遷移
ユーザーが上記のような操作をした場合、最初に入力した法人情報が残っていることが期待されます。
エッジケースであり破棄されても大きな問題はありませんが、この挙動を実現するため両方のパターンの値を保持する設計を採用しました。
なぜ確認画面でも保持するのか
確認画面で検証前の値を破棄しても大きな問題はありません。
しかし、保持し続けることで、確認画面から各ステップに戻った際も上記の「Step2の動的な項目への対応」が実現できます。
なお更新フォームの場合は、検証前の値は存在しない状態から始まります。
type DraftValues = { step1: Step1InputSchema step2: { 法人: Step2法人InputSchema 個人事業主: Step2個人事業主InputSchema } step3: Step3InputSchema }
zodのdiscriminatedUnionについて
動的なスキーマの定義にはzodの discriminatedUnion が使えそうですが、 discriminatedUnion を使用するとinput、output両方の型が動的に扱われてしまいます。
discriminatedUnion を使わない選択肢もあるかもしれません。
discriminatedUnionを使う場合
const schema = z.discriminatedUnion('type', [ z.object({ type: z.literal('a'), fieldA: z.string() }), z.object({ type: z.literal('b'), fieldB: z.string() }) ]) // z.input<typeof schema> // { type: "a"; fieldA: string; } | { type: "b"; fieldB: string; } // z.output<typeof schema> // { type: "a"; fieldA: string; } | { type: "b"; fieldB: string; }
discriminatedUnionを使わない場合
const schema = z.object({ type: z.union([z.literal('a'), z.literal('b')]), fieldA: z.string().optional(), fieldB: z.string().optional() }).transform(v => { if (v.type === 'a') { return { type: 'a', fieldA: v.fieldA || '' } } return { type: 'b', fieldB: v.fieldB || '' } }) // z.input<typeof schema> // { type: "a" | "b"; fieldA?: string; fieldB?: string; } // z.output<typeof schema> // { type: "a"; fieldA: string; } | { type: "b"; fieldB: string; }
出力値 (検証後の値)
検証後の値はステップごとにネストした構造で管理します。
この設計により、Step1の organizationType に基づいて型を適切に絞り込むことができます。
type OutputValues<T extends '法人' | '個人事業主'> = T extends '法人' ? { step1: { organizationType: '法人' } step2: { '法人': Step2法人OutputSchema // 法人の場合のみ法人フィールドが存在 } step3: Step3OutputSchema } : { step1: { organizationType: '個人事業主' } step2: { '個人事業主': Step2個人事業主OutputSchema // 個人事業主の場合のみ個人事業主フィールドが存在 } step3: Step3OutputSchema }
以下のようなフラットな構造も検討しました
type OutputValues<T extends '法人' | '個人事業主'> = T extends '法人' ? { organizationType: '法人' } & Step2法人OutputSchema & Step3OutputSchema : { organizationType: '個人事業主' } & Step2個人事業主OutputSchema & Step3OutputSchema
しかし、フォームが複雑になるにつれて以下の問題が発生します:
- フィールド名の重複
- Form間で同じ名前のフィールドが存在する場合に型が競合
- バリデーションルールの競合
- 同じフィールド名でもステップごとに異なるルールが必要
- 例:一方は必須、もう一方は任意項目
- 保守性の低下
- どの項目がどのステップに属するか不明確
- ステップの追加・削除時の影響範囲が把握しづらい
また、この後の状態遷移のセクションで紹介しますが、OutputValuesから一部を破棄するフローがあります。
ステップ毎に構造化されていない場合、その実装が複雑になります。
各ステップの状態
次に状態の遷移ロジックを定義していきます。
「各ステップで使用する共通の型」セクションで紹介した型も使用します。
formStateReducer.ts のStateとなります。
確認画面
すべてのステップの出力値を保持します。
出力値は画面表示、API送信を行うために必要です。
type ConfirmState<T extends '法人' | '個人事業主'> = { mode: 'confirm' payload: { draftValues: DraftValues outputValues: OutputValues<T> } }
修正画面
すべてのステップの出力値を保持します。
編集対象のステップ以外の出力値は、編集の確定後に確認画面へ遷移するために必要です。
確認画面に戻る導線を確保するために編集対象のステップの出力値も保持する必要があります。
type EditState<T extends '法人' | '個人事業主'> = { mode: 'edit' step: 1 | 2 | 3 payload: { draftValues: DraftValues outputValues: OutputValues<T> } }
下書き画面
確定したステップの出力値を保持します。
type DraftStep1State = { mode: 'draft' step: 1 payload: { draftValues: DraftValues } } type DraftStep2State = { mode: 'draft' step: 2 payload: { draftValues: DraftValues outputValues: { step1: Step1OutputSchema } } } type DraftStep3State<T extends '法人' | '個人事業主'> = { mode: 'draft' step: 3 payload: { draftValues: DraftValues outputValues: T extends '法人' ? { step1: { organizationType: '法人' } step2: { 法人: Step2法人OutputSchema } } : { step1: { organizationType: '個人事業主' } step2: { 個人事業主: Step2個人事業主OutputSchema } } } }
状態まとめ
以下がすべての状態となります。
type State<T extends '法人' | '個人事業主'> = | ConfirmState<T> | EditState<T> | DraftStep1State | DraftStep2State | DraftStep3State<T>
状態の遷移
ステップ間の遷移は以下の5つのパターンに分類できます:
- 次のステップへ: 現在のステップから次のステップへ
- 前のステップへ: 現在のステップから前のステップへ
- 編集: 確認画面から任意のステップへ
- 編集を確定: 編集画面から確認画面へ
- 編集をキャンセル: 編集画面から確認画面のステップへ
各アクションに currentState として現在の状態を渡すように設計しました。
reducerでは前回のStateを渡さなくても参照可能なため冗長に感じますが、Step1の値による動的な型の絞り込みを考慮すると、渡したほうが扱いやすく感じました。
前回のstateとactionの型をセットで定義することもできなくはないかもしれませんが、非常に分岐の多い可読性の低いConditional Typeとなりそうだったため見送りました。
アクションが呼ばれたら新しいStateに変更される一方向の状態の変化となります。
※ currentState を渡さなくてもうまく型を絞り込める方法があれば教えて下さい
前回のstateとactionの型をセットで定義する例
type RestrictedReducer = < OrganizationType extends '法人' | '個人事業主', Step extends 1 | 2 | 3, State extends | ConfirmState<OrganizationType> | EditState<OrganizationType, Step> | DraftStep1State | DraftStep2State, ... >( state: State, action: State extends ConfirmState<OrganizationType> ? OnEditAction : State extends EditState<OrganizationType, Step> ? | OnCancelEditAction<OrganizationType> | (Step extends 1 ? OnSubmitStep1DraftAction : Step extends 2 ? OnSubmitStep2DraftAction<OrganizationType> : OnSubmitStep3DraftAction<OrganizationType>) : S extends DraftStep1State ... ) => S
1. 次のステップへ
次のステップへの遷移では確定した値をOutputValuesに保持します。
Step 1 -> 2
Step1からStep2への遷移では、選択された organizationType に応じて適切な入力フォームを表示する必要があります。
この際、Step1の出力値を保持しつつ、入力値(draftValues)も更新することで、ユーザーが前のステップに戻った際も値を保持できるようにしています。
// action type OnSubmitStep1DraftAction = { currentState: DraftStep1State type: 'onSubmitDraft' currentStep: 1 payload: { outputValues: OutputValues<'法人' | '個人事業主'>['step1'] } } // New State { mode: 'draft', step: 2, payload: { outputValues: { step1: action.payload.outputValues }, draftValues: { ...action.currentState.payload.draftValues, // 必要があれば値を変換して代入する (number -> stringなど) step1: action.payload.outputValues, } } }
Step 2 -> 3
// action type OnSubmitStep2DraftAction<T extends '法人' | '個人事業主'> = { currentState: DraftStep2State type: 'onSubmitDraft' currentStep: 2 payload: { outputValues: OutputValues<T>['step2'] } } // New State { mode: 'draft', step: 3, payload: { outputValues: { ...action.currentState.payload.outputValues, step2: action.payload.outputValues }, draftValues: { ...action.currentState.payload.draftValues, step2: action.payload.outputValues, } } }
Step 3 -> 確認画面
// action type OnSubmitStep3DraftAction<T extends '法人' | '個人事業主'> = { currentState: DraftStep3State<T> type: 'onSubmitDraft' currentStep: 3 payload: { outputValues: OutputValues<T>['step3'] } } // New State { mode: 'confirm', payload: { outputValues: { ...action.currentState.payload.outputValues, step3: action.payload.outputValues }, draftValues: { ...action.currentState.payload.draftValues, step3: action.payload.outputValues, } } }
2. 前のステップへ
前のステップへの遷移では確定した値をOutputValuesから破棄します。
Step 2 -> 1
// action type OnBackToStep1Action = { currentState: DraftStep2State type: 'onBack' backToStep: 1 payload: { draftValues: DraftValues['step2'] } } // New State { mode: 'draft', step: 1, payload: { draftValues: { ...action.currentState.payload.draftValues, step2: action.payload.draftValue, } } }
Step 3 -> 2
// action type OnBackToStep2Action<T extends '法人' | '個人事業主'> = { currentState: DraftStep3State<T> type: 'onBack' backToStep: 2 payload: { draftValues: DraftValues['step3'] } } // New State { mode: 'draft', step: 2, payload: { outputValues: { step1: action.currentState.outputValues.step1 }, draftValues: { ...action.currentState.payload.draftValues, step3: action.payload.draftValues, } } }
3. 編集
確認画面からの編集では画面遷移に関する値のみ更新します。
確認画面 -> Step 1 〜 3
// action type OnEditAction<T extends '法人' | '個人事業主'> = { currentState: ConfirmState<T> type: 'onEdit' payload: { step: 1 | 2 | 3 } } // New State { mode: 'edit', step: 1 | 2 | 3, payload: action.currentState.payload }
4. 編集を確定
編集の確定ではOutputValuesを更新します。
Step 1 -> Step 2
// action type OnSubmitStep1EditAction<T extends '法人' | '個人事業主'> = { currentState: EditState<T> type: 'onSubmitEdit' currentStep: 1 payload: { outputValues: OutputValues<T>['step1'] } } // New State { mode: 'draft', step: 2, payload: { outputValues: { step1: action.payload.outputValues }, draftValues: { ...action.currentState.payload.draftValues, step1: action.payload.outputValues } } }
Step 2 -> 確認画面
// action type OnSubmitStep2EditAction<T extends '法人' | '個人事業主'> = { currentState: EditState<T> type: 'onSubmitEdit' currentStep: 2 payload: { outputValues: OutputValues<T>['step2'] } } // New State { mode: 'confirm', payload: { outputValues: { ...action.currentState.payload.outputValues, step2: action.payload.outputValues }, draftValues: { ...action.currentState.payload.draftValues, step2: action.payload.outputValues } } }
Step 3 -> 確認画面
// action type OnSubmitStep3EditAction = { currentState: EditState<T> type: 'onSubmitEdit' currentStep: 3 payload: { outputValues: OutputValues<'法人' | '個人事業主'>['step3'] } } // New State { mode: 'confirm', payload: { outputValues: { ...action.currentState.payload.outputValues, step3: action.payload.outputValues }, draftValues: { ...action.currentState.payload.draftValues, step3: action.payload.outputValues } } }
5. 編集をキャンセル
編集のキャンセルでは画面遷移に関する値のみ更新します。
Step 1 〜 3 -> 確認画面
// action type OnCancelEditAction<T extends '法人' | '個人事業主'> = { currentState: EditState<T> type: 'onCancelEdit' } // New State { mode: 'confirm', payload: action.currentState.payload }
状態遷移の全体像
以下の図は、本実装における状態遷移を表したものです。
単一Formではこれらの遷移を一切考える必要がないのに対して複数ステップFormはこれらの考慮が必要なので複雑になります。


親コンポーネント
最後に、定義したReducerとステップごとのコンポーネントを統合します。 ここでは、formStateのmode、step、organizationTypeに基づいて適切なコンポーネントをレンダリングします。
実装のポイント
- 初期状態の設定: 新規作成時はDraftStep1Stateから開始
- 条件分岐の最適化: mode -> step -> organizationTypeの順で分岐
- 型安全性の確保: dispatchFormStateの引数はformStateの型に基づいて自動的に推論
※ mode: 'draft' と 'edit' をそれぞれ分けてコンポーネントを配置していますが、or条件を使用し配置しても問題はありません。
export const MultiStepForm = () => { const [formState, dispatchFormState] = useReducer( FORM_STATE_REDUCER, // 状態の遷移セクションで定義したreducer { mode: 'draft', step: 1, payload: { draftValues: { step1: { organizationType: null, }, step2: { '法人': { corporateNumber: null, organizationName: null, }, '個人事業主': { organizationName: null, name: null, }, }, step3: { email: null, }, }, }, } satisfies DraftStep1State ) return ( <> {formState.mode === 'draft' && ( <> {formState.step === 1 && ( <Step1Form defaultValues={formState.payload.draftValues.step1} onSubmit={(d) => dispatchFormState({ currentState: formState, type: 'onSubmitDraft', currentStep: 1, payload: { outputValues: d }, }) } /> )} {formState.step === 2 && (formState.payload.outputValues.step.organizationType === '法人' ? ( <Step2法人Form defaultValues={formState.payload.draftValues.step2['法人']} onSubmit={(d) => dispatchFormState({ currentState: formState, type: 'onSubmitDraft', currentStep: 2, payload: { outputValues: d }, }) } onBack={(d) => dispatchFormState({ currentState: formState, type: 'onBack', backToStep: 1, payload: { draftValues: d }, }) } /> ) : ( <Step2個人事業主Form defaultValues={formState.payload.draftValues.step2['個人事業主']} onSubmit={(d) => dispatchFormState({ currentState: formState, type: 'onSubmitDraft', currentStep: 2, payload: { outputValues: d }, }) } onBack={(d) => dispatchFormState({ currentState: formState, type: 'onBack', backToStep: 1, payload: { draftValues: d }, }) } /> ))} {formState.step === 3 && ( <Step3Form defaultValues={formState.payload.draftValues.step3} onSubmit={(d) => dispatchFormState({ currentState: formState, type: 'onSubmitDraft', currentStep: 3, payload: { outputValues: d }, }) } onBack={(d) => dispatchFormState({ currentState: formState, type: 'onBack', backToStep: 2, payload: { draftValues: d }, }) } /> )} </> )} {formState.mode === 'edit' && ( <> {formState.step === 1 && ( <Step1Form defaultValues={formState.payload.draftValues.step1} onSubmitEdit={(d) => dispatchFormState({ currentState: formState, type: 'onSubmitEdit', currentStep: 1, payload: { outputValues: d }, }) } onCancelEdit={() => dispatchFormState({ currentState: formState, type: 'onCancelEdit', })} /> )} {formState.step === 2 && (formState.payload.outputValues.step.organizationType === '法人' ? ( <Step2法人Form defaultValues={formState.payload.draftValues.step2['法人']} onSubmit={(d) => dispatchFormState({ currentState: formState, type: 'onSubmitEdit', currentStep: 2, payload: { outputValues: d }, }) } onCancelEdit={() => dispatchFormState({ currentState: formState, type: 'onCancelEdit', })} /> ) : ( <Step2個人事業主Form defaultValues={formState.payload.draftValues.step2['個人事業主']} onSubmit={(d) => dispatchFormState({ currentState: formState, type: 'onSubmitEdit', currentStep: 2, payload: { outputValues: d }, }) } onCancelEdit={() => dispatchFormState({ currentState: formState, type: 'onCancelEdit', })} /> ))} {formState.step === 3 && ( <Step3Form defaultValues={formState.payload.draftValues.step3} onSubmit={(d) => dispatchFormState({ currentState: formState, type: 'onSubmitEdit', currentStep: 3, payload: { outputValues: d }, }) } onCancelEdit={() => dispatchFormState({ currentState: formState, type: 'onCancelEdit', })} /> )} </> )} {formState.mode === 'confirm' && ( <Confirm data={formState.payload.outputData} onEdit={(step) => dispatchFormState({ currentState: formState, type: 'onEdit', payload: { step }, })} /> )} </> ) }
これで状態の遷移を実現できました
APIエラーのハンドリング
複数ステップFormではAPIエラーのハンドリングも単一Formに比べて複雑になるケースがあります。
単一Formであれば対象の項目にエラーをマッピング、マッピングできない場合はFormのどこかにエラーを表示すればよいことが多いと思います。
複数ステップFormの場合はそうはいきませんが、今回のように確認画面を設けた場合は確認画面にエラーを表示すればそこまで問題はないかもしれません。
ただしAPIリクエストは必ず確認画面から行うため再度エラーになった場合、何度も編集画面と確認画面を行き来する必要があるかもしれません。
確認画面を設けなかった場合は特定にステップに遷移させるなど、工夫が必要になりそうです。
ただしAPIが判定に必要な情報を返却する仕様になっているとは限りません、、
単一Formのエラー

複数ステップFormのエラー

あとがき
型安全性が担保され状態の遷移が明確な一方、型の定義や各Formの検証前のデフォルト値の保持など複雑な設計をする必要があり頭が痛いです。
代替案として非表示となったコンポーネントをアンマウントせずCSSで非表示にして検証前の値を保持するなど他のアプローチの検討の余地もありそうです。
また今回紹介した事以外にもブラウザバックした際はどうするかなど考えないといけないことが隠れていそうです。
今回のような確認画面をあわせて4分割されたFormの場合、単一Formと比べて工数が3倍以上かかっても不思議に思いません。
今回紹介した設計とは異なっていても、実装に入る前にこれらの設計を細部まで明確に定義することが重要だと思います。
We Are Hiring !!
UPSIDERでは現在積極採用をしています。 ぜひお気軽にご応募ください。
カジュアル面談はこちら!
UPSIDER Engineering Deckはこちら📣