組み合わせ型Formコンポーネントの設計

組み合わせ型Formコンポーネントの設計

はじめに

最近のプロジェクトで、React Hook Form(RHF)とMaterial UI(MUI)をベースに、さまざまなフォーム画面を実装しました。

プロジェクトの初期段階では、フォームUIの一貫性を維持するために共通Fieldコンポーネントを構築し、使用していました。

<Field.Text
  name="name"
  label="Name"
/>

開発者は上記のような方法で入力フィールドをすばやく構成でき、React Hook FormとMUIを直接接続する必要もなく、共通コンポーネントだけを使用すればよい状態でした。

実際のサービスでは、次のようなさまざまな入力コンポーネントを提供していました。

  • Field.Text

  • Field.Select

  • Field.Autocomplete

  • Field.DatePicker

  • Field.DateTimePicker

  • Field.Checkbox

当初は、このような構造が開発生産性とUIの一貫性の面で十分に効果的だと考えていました。

しかし、プロジェクトが拡大し、画面の要件が多様化するにつれて、既存構造の限界が徐々に明らかになり始めました。

モノリシック構造の限界

初期のFieldコンポーネントは、Labelの出力、Validationの処理、React Hook Formとの接続、Inputのレンダリングまで、すべての役割を1つのコンポーネント内部で処理する構造でした。

Field
├─ FormLabel
├─ Controller
└─ Input Component

この構造は、一般的な入力画面では使いやすいものでした。

しかし実際のサービスでは、単純な入力フィールドよりも複合的な形のUIが頻繁に登場しました。

Inputの横にボタンがあるUI

代表的な例として、入力フィールドの横にボタンが配置されるUIがあります。

たとえば、重複確認、認証番号のリクエスト、住所検索などの機能は、次のような形で実装されます。

image1.png

既存のField.Textは、Label、Input、HelperTextが1つのコンポーネントにまとめられていたため、単純なFlex配置だけでは意図したレイアウトを実装するのが困難でした。

  • align-items: flex-startを使用すると、Labelとボタンが同じ高さに揃う

  • align-items: centerを使用すると、Inputとボタンの垂直方向の配置が不自然になる

  • align-items: flex-endを使用すると、通常時は問題ないものの、エラーメッセージが表示された場合にボタンの位置も押し下げられ、配置が崩れる

結局、ボタンの位置を合わせるために、Labelの高さと間隔を計算したmargin-top: 22pxのようなハードコーディングが必要でした。

これは、Label、Input、HelperTextが強く結合された構造が、複合UIの構成には適していないことを示す事例でした。

範囲入力(Range Field)のHelperTextに関する問題

もう1つの事例は、範囲入力UIです。

範囲入力はユーザーから見ると1つの入力項目ですが、実際の実装では開始値と終了値という2つの入力フィールドで構成されます。

image2.png

既存の構造では、それぞれの入力フィールドが独立したFieldとして動作していたため、Validation Errorが発生するとHelperTextも個別に出力されました。

特に幅の狭い画面では、エラーメッセージが複数行に折り返され、全体の高さが大きく増加する問題が発生しました。

また、開始値と終了値のエラーメッセージの長さが異なる場合、レイアウトの高さが変わり、画面が不安定に見える問題もありました。

ユーザーは「範囲」という1つの値を入力しているにもかかわらず、エラーメッセージが2つに分散して表示される点も、UXの観点では残念な部分でした。

共通コンポーネントを迂回するケースの発生

プロジェクトが複雑になるほど、一部の画面では既存のFieldコンポーネントだけで要件を満たすことが難しくなりました。

その結果、一部の画面では共通コンポーネントを使用せず、React Hook FormのControllerとMUIコンポーネントを直接使用する方法が使われ始めました。

<Controller
  name="period"
  control={control}
  render={({ field }) => (
    <TextField {...field} />
  )}
/>

共通コンポーネントが存在するにもかかわらず迂回実装が発生するということは、そのコンポーネントの拡張性が不足している兆候でした。

この問題を解決するため、既存の構造を改めて見直すことにしました。

Composition構造を検討するようになった理由

既存構造における最大の問題は、1つのコンポーネントがあまりにも多くの責任を担っていたことでした。

これを解決するため、ReactのComposition Patternを検討することにしました。

Compositionとは、1つの巨大なコンポーネントがすべての機能を担当するのではなく、小さな役割単位のコンポーネントを組み合わせてUIを構成する方法です。

代表的な例は次のとおりです。

<Card>
  <Card.Header />
  <Card.Body />
  <Card.Footer />
</Card>

各コンポーネントは自分の役割に集中し、必要な形に自由に組み合わせることができます。

この構造はDesign Systemでもよく使用されるパターンであり、拡張性と再利用性が高いというメリットがあります。

Fieldを役割中心に分離する

従来は、1つのコンポーネントがLabel、Validation、Formとの接続、Errorの出力まですべて担当していました。

これを次のように役割単位で分離しました。

<Field>
  <Field.Label />
  <Field.Control />
  <Field.HelperText />
</Field>

各コンポーネントは次のような役割を担当します。

  • Field:レイアウトの構成および共通状態の管理

  • Field.Label:ラベルの出力

  • Field.Control:React Hook Formとの接続および入力要素のレンダリング

  • Field.HelperText:エラーメッセージおよび案内文の出力

役割を分離することでコンポーネントの責務が明確になり、UIをより柔軟に構成できるようになりました。

最大の変化、Field.Group

今回のリファクタリングにおける最も重要な変化は、Field.Groupの導入でした。

実際のサービスでは、複数の入力要素が1つの意味を持つケースが多くあります。

代表的な例が期間選択UIです。

<Field>
  <Field.Label required>
   기간
  </Field.Label>

  <Field.Group>
   <Field.Control name="startDate" />
   <span>~</span>
   <Field.Control name="endDate" />
  </Field.Group>

  <Field.HelperText />
</Field>

従来の構造では開始日と終了日がそれぞれ独立したフィールドとして動作していたため、エラーメッセージが重複して表示され、グループ単位のUXを提供することが困難でした。

一方、Field.Groupを導入した後は、複数の入力要素を1つのフィールド単位で管理できるようになり、レイアウトとValidationの処理もより自然に構成できるようになりました。

組み合わせ型の構造がもたらした拡張性

Composition構造を導入した後は、新たな要件が発生しても専用コンポーネントを別途作成する必要がほとんどなくなりました。

例えば、タグ入力UIも既存のコンポーネントを組み合わせて実装できます。

<Field>
  <Field.Label>
    Tags
  </Field.Label>

  <Field.Control
    render={() => (
      <>
        <TextField />
        <ChipList />
      </>
    )}
  />

  <Field.HelperText />
</Field>

このように、新しい機能のためにコンポーネントを追加し続けるのではなく、既存のコンポーネントを組み合わせてさまざまなUIを実装できるようになりました。

適用結果

Composition構造を適用した結果、以下のような効果を得ることができました。

  • Form UI構造の一貫性を確保

  • 複合入力UIへの対応力を向上

  • 新しい入力タイプの追加が容易

  • MUI slotPropsの活用性が向上

  • 共通コンポーネントを迂回して実装するケースが減少

  • 保守コストを削減

  • 再利用性が向上

特に新たな要件が発生しても、既存のコンポーネントを修正するのではなく組み合わせによって解決できるようになり、拡張性が大きく向上しました。

おわりに

当初は、単純にReact Hook Formラッパーコンポーネントを改善する作業だと考えていました。

しかし実際には、「共通コンポーネントをどのように設計すれば長期的に拡張可能になるのか」という検討のほうが、より重要な課題でした。

今回の経験を通じて、すべての機能を1つのコンポーネントに追加するMonolithic方式よりも、役割を分離し、必要な機能を組み合わせられるComposition方式のほうがはるかに柔軟であることを確認できました。

今後もFormコンポーネントだけでなく、Design System全体にこのような設計方式を適用し、再利用性と拡張性を高めていく予定です。

nature

Site footer