【フロントエンド】依存関係を持つフォーム設計の課題と解決
はじめに
こちらは e-dash advent calendar 2025 の 24 日目の記事です。
こんにちは。e-dash でフロントエンドエンジニアをしている nakano です。
弊社では、エネルギー排出量削減に関する SaaS を開発しています。プロダクト開発部では、ユーザーの価値向上を目的に、日々既存機能の改善や新規機能開発に取り組んでいます。
プロジェクトの規模は大小さまざまで、比較的大きな機能開発をチームで進めることも少なくありません。最近では、複数のフォームタイプと複雑な依存関係を持つ新規機能の開発を担当しました。
弊社では、課題整理からデザイン作成、仕様策定、設計、実装までを一貫してチームで行っています。
この記事では、今回のプロジェクトで直面した課題と、その中で得られた学びについて紹介します。
記事の対象者
- フロントエンドエンジニア
- SaaS プロダクトの開発に興味がある方
- チーム開発や設計に興味がある方
開発環境
- TypeScript + React
- フォーム管理:React Hook Form
- バリデーション:Zod
フォームの仕様
- 複数のフォームタイプを 1 つの画面で管理
- 各項目に依存関係が存在する
- 一部項目は API 連携が必要
仕様の概要
このフォームは、複数のフォームタイプを配列で管理する仕様になっています。ユーザーは 1 つの画面で複数のレコードを追加・編集でき、各レコードは異なるフォームタイプを持つことができます。
具体例:請求書の登録フォーム
実際の実装では、請求書の登録フォームは以下のような仕様になっています:
- フォームタイプ:電気、ガス、その他
- 配列管理:1 つの画面で複数の請求書データを同時に登録可能
{
records: [
{ type: 'electricity', branchId: 1, ... }, // 電気の請求書
{ type: 'gas', branchId: 2, ... }, // ガスの請求書
]
}
項目の依存関係とは:
依存関係とは、ある項目の入力可否や選択肢が、他の項目の値によって決まる関係のことです。
具体例:電気の請求書の場合
- 拠点(必須)→ 選択すると次が有効化
- 対象年月(必須)→ 拠点が選択されている場合のみ入力可能
- 供給会社・プラン(必須)→ 対象年月が選択されている場合のみ選択可能(API で取得)
このように、フォームタイプごとに依存関係のパターンが異なるため、実装が複雑になっていました。
依存関係の全体像
複数のフォームタイプが存在し、それぞれ異なる依存関係を持っています。イメージ的には以下のような構造です。
- フォームタイプ 1:拠点 → 対象年月 → 供給会社・プラン → 入力項目
- フォームタイプ 2:拠点 → 対象年月 → 種別詳細 → 供給会社・プラン → 入力項目
- フォームタイプ 3:拠点 → 対象年月 → 種別詳細 → 入力項目
スキーマ定義による仕様整理
コンポーネント実装前に、Zod のスキーマ定義で仕様を整理しました。これにより、フォームタイプごとの差分やバリデーションルールを明確にできます。
スキーマ定義の概要
複数のレコード型を z.union でまとめ、フォームタイプごとに異なるバリデーションを定義しました。
export const formSchema = z.object({
records: z.array(
z.union([type1Record, type2Record, type3Record, unknownRecord])
),
});
ポイント:
-
recordsは配列(z.array)で、複数のフォームタイプを同時に管理します - 各レコードは
z.unionにより、フォームタイプに応じた異なるバリデーションルールが適用されます - 各フォームタイプ用のレコード型(
type1Record,type2Record,type3Record) - 初期状態(未選択)用の
unknownRecord
共通バリデーションルールの再利用
課題: 複数のフォームタイプで共通するバリデーションロジックが各コンポーネントに散在し、ロジックの分離とリファクタリングが困難でした。また、重複コードにより不要な依存関係が生じる可能性がありました。
対応: 共通ルールは親スキーマに集約し、各レコード型で再利用しました。
設計判断の理由:
- 複数フォームタイプ間で一貫性を保つ
- バリデーションロジックを一元管理し、リファクタリングを容易にする
- コンポーネント間の不要な依存関係を排除し、責務を明確化する
デフォルト値の定義
React Hook Form の useFieldArray では、append などで新しいレコードを追加する際に 完全な型の値が必要になります。そのため、「一部のフィールドだけを未定義(partial)のまま扱う」といった設計が取りづらい制約があります。
export const defaultType1Record = {
field1: "" as unknown as number,
field2: 0,
};
補足:
今回のフォームでは、フォームタイプごとに入力必須項目が異なるため、「未選択状態をどのように表現するか」が設計上の課題になりました。
この制約を踏まえ、本実装では以下の方針を採用しています。
- 初期表示時は 未入力状態を表す値(空文字列など)を型アサーションで定義
- 実際の値の正当性は Zod スキーマの
coerceで型変換・バリデーションを担保 - React Hook Form では「状態管理」に専念させ、仕様判断はスキーマ側に寄せる
この役割分担により、React Hook Form の制約を回避しつつ、型安全性と実装の見通しを両立できました。
参考記事: React Hook Form と Zod を組み合わせた際の型安全性について、同じチームの maeda さんが型アサーションの使用や、制御コンポーネントの扱いについての詳細な記事を書いています 👍
特徴的な実装ポイント
-
型アサーションの活用
- ReactHookForm の制約を回避するために使用
-
表示専用フィールドの定義
- 確認画面などで使用する表示用データを別管理
-
未選択時のカスタムバリデーション
-
superRefineを使ってエラーメッセージを制御
-
実装面で工夫したこと
スキーマ定義で仕様を明確化した後、実装時に以下の点を考慮しました。
1:依存関係の管理
課題:
-
前の項目が未選択だと次が入力できない
-
親項目変更時に子項目をリセットする必要がある
-
フォームタイプごとに依存関係が異なる
-
再利用できるコンポーネントにする
対応:
useWatch + disabled で依存関係を明確化
改善点:
-
useWatchで依存値をリアルタイムに監視 -
disabled条件で依存関係を可視化 -
依存関係がコードから明確に読み取れる
フォームタイプごとのコンポーネント分離
依存関係が異なるため、フォームタイプごとにコンポーネントを分離しました。
{field.type === TYPE1 ? (
<Type1Fields ... />
) : field.type === TYPE2 ? (
<Type2Fields ... />
) : (
<DefaultFields ... />
)}
設計判断の理由:
-
依存関係をコンポーネント単位で完結させる
-
未選択時はデフォルト表示で選択を促す
useWatch による依存関係の監視
useWatch で必要な値だけを監視し、リアルタイムに依存値を取得します。
const [field1, field2] = useWatch({
control: methods.control,
name: [`records.${index}.field1`, `records.${index}.field2`],
});
設計判断の理由:
-
必要な値のみ監視してパフォーマンスを維持
-
disabledや API 条件に利用
disabled による段階的な入力制御
disabled で依存関係を可視化し、段階的な入力制御を実現します。
<Field2Group
disabled={!field1}
...
/>
設計判断の理由:
-
依存関係を UI 上で明確化
-
条件がコードから読み取りやすい
親項目変更時のリセット処理
親項目変更時は、依存する子項目をリセットすることで、不整合を防止します。
onChangeParentField={(option) => {
if (option) {
methods.setValue(`records.${index}.parentFieldName`, option.text);
}
// 親項目変更時に子項目をリセット
methods.resetField(`records.${index}.childFieldId`);
methods.resetField(`records.${index}.childFieldName`);
}}
設計判断の理由:
- 不整合防止
- 表示用フィールドも同時更新
2:API 連携のタイミング制御
課題:
-
常に取得可能な項目
-
条件が揃わないと取得できない項目が混在
対応:
API 呼び出しの最適化
条件が必要な項目の取得は、条件値を useMemo でメモ化し、条件に応じて適切な値を渡すことで実現しています。条件が揃わない場合は undefined となり、API が適切に処理します。
const conditionValue = useMemo(
() => getConditionValue(fieldValue),
[fieldValue]
);
const apiData = useGetApiData({
conditionValue,
path: { company_id: companyId || "" },
});
設計判断の理由:
- 条件に応じた API 呼び出しの制御
-
useMemoによる不要な再計算の防止 - コンポーネントの再レンダリングを最小化
まとめ
複数のフォームタイプと複雑な依存関係を持つ機能開発では、以下のアプローチが有効でした。
- スキーマ定義で仕様を明確化:Zod スキーマでフォームタイプごとの差分とバリデーションルールを明確化し、実装前に仕様を整理
- フォームタイプごとのコンポーネント分離:依存関係をコンポーネント単位で完結させ、責務を明確化
-
useWatchとdisabledによる依存関係管理:リアルタイム監視と段階的な入力制御で UX を向上 -
条件付き API 呼び出しの最適化:
useMemoによる条件計算で不要な API 呼び出しを防止し、パフォーマンスを維持
今回の開発では、UX・パフォーマンス・設計のバランスを意識しました。特に、スキーマ定義を最初に行うことで、実装時の迷いが減り、チーム内での認識合わせもスムーズに進みました。
余談ですが、PR を小さく分割することも意識しました。その結果、レビュー精度が向上し、仕様やテストの抜け漏れを防ぐことにつながりました。
チームで仕様を深く考え、レビュー・検証を重ねられる環境だからこそ、多くの学びを得られたプロジェクトだったと感じています。
Discussion