🐣

【フロントエンド】依存関係を持つフォーム設計の課題と解決

に公開

はじめに

こちらは 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, ... }, // ガスの請求書
    ]
  }

項目の依存関係とは:

依存関係とは、ある項目の入力可否や選択肢が、他の項目の値によって決まる関係のことです。

具体例:電気の請求書の場合

  1. 拠点(必須)→ 選択すると次が有効化
  2. 対象年月(必須)→ 拠点が選択されている場合のみ入力可能
  3. 供給会社・プラン(必須)→ 対象年月が選択されている場合のみ選択可能(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 さんが型アサーションの使用や、制御コンポーネントの扱いについての詳細な記事を書いています 👍
https://zenn.dev/edash_tech_blog/articles/df047a8ac3f69e

特徴的な実装ポイント

  1. 型アサーションの活用

    • ReactHookForm の制約を回避するために使用
  2. 表示専用フィールドの定義

    • 確認画面などで使用する表示用データを別管理
  3. 未選択時のカスタムバリデーション

    • 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