【フロントエンド改修事例】既存プロダクトのReactにCompound Components Patternを導入しました【コンポーネント設計】

はじめに

この記事は「ウィルゲート Advent Calendar 2024」の 18 日目の記事です! adventar.org

こんにちは!ウィルゲート開発グループの中尾(のまど )です!

弊社のオンライン記事編集サービスEditorUの改修を中心に、フロントエンドからバックエンドまで広くWeb開発を担当しています。

client.editoru.jp

今回EditorUで新規画面の設計・実装を担当し、その過程でCompound Components Patternを導入しました。

本記事では、導入に至った背景や具体的な実装内容、導入後に得られた成果についてご紹介します。

Compound Components Patternとは?

Compound Components PatternはReactのデザインパターンの一つで、親コンポーネントが子コンポーネントを構成要素として提供し、子コンポーネントが連携して一つの機能を実現する設計手法です。

特にタブやモーダルのようなUIを構築する際に、子コンポーネントを親コンポーネント内で柔軟に組み合わせられるため、機能をシンプルかつカスタマイズしやすくなります。

www.patterns.dev

具体例

モーダルの例

function Modal({ children }) {
  return <div className="modal">{children}</div>;
}

Modal.Header = function Header({ title }) {
  return <div className="modal-header">{title}</div>;
};

Modal.Body = function Body({ children }) {
  return <div className="modal-body">{children}</div>;
};

Modal.Footer = function Footer({ actions }) {
  return <div className="modal-footer">{actions}</div>;
};

// 使用例
<Modal>
  <Modal.Header title="サンプルモーダル" />
  <Modal.Body>
    <p>モーダルの内容です。</p>
  </Modal.Body>
  <Modal.Footer actions={<button>閉じる</button>} />
</Modal>;

このように、親コンポーネント(Modal)が子コンポーネント(Header、Body、Footer)を柔軟に組み合わせることで、一つの機能を効率的に実現します。

コンポーネントの依存関係

Compound Componentsでモーダル設計時の依存関係

メリット

  • 柔軟性の向上: 子コンポーネントの配置や構成を自由に変更できる。
  • 再利用性の向上: 子コンポーネントが独立しており、異なるコンポーネント間でも再利用しやすい。
  • シンプルな設計: 親コンポーネントが状態や振る舞いを強制せず、子コンポーネントは役割に集中できる。

導入の背景と課題

EditorUでは立ち上げ期から6年が経ち、その間に幾度もリアーキテクトを経て以下の設計方針で進んでいました。

  • コンポーネント分割: Atomic DesignとfeatureDirectoryの混在
    • Atomic Design: コンポーネントを「Atoms(最小単位)」「Molecules(組み合わせ)」「Organisms(画面部品)」などに分割して設計する手法。
    • featureDirectory(package by feature): 特定の機能単位でディレクトリを構成する設計手法。
  • 状態管理: Reduxを使用したre-ducks Pattern。ただし、主にAPI通信とそのレスポンス管理が中心
    • Redux: 状態管理ライブラリで、複数のコンポーネント間で状態を一元管理する仕組み。

当初はこれで問題ありませんでしたが、開発が進むにつれ次の課題が顕在化しました。

課題 内容
Propsのバケツリレー 中間コンポーネントを経由するデータ伝達が増え、コードの可読性が低下。
Reduxのオーバーヘッド API通信中心に運用していたが、あらゆるコンポーネントから呼ばれることで結果神クラスのようになり、依存関係が追いづらくなっていた。 またUIコンポーネントの責務が不明確になっていた。
Formikの制約 当時Formik をAPI通信に使用していたが、バージョンが古くuseFormikContext()が利用できなかった。そのため状態共有の工夫が必要だった。

Propsのバケツリレーとは: 親コンポーネントから子孫コンポーネントへデータを渡す際に中間コンポーネントを経由し、コードが複雑化する状態です。

ParentComponent
   └── ChildComponentA
        └── ChildComponentB
             └── ChildComponentC
                  └── Props

これらの課題を解決するため、Compound Components Patternを導入しました。

ディレクトリ構造の比較

Before: Compound Components導入前

src/
└── コンポーネントA/
    └── Form/
        ├── component.tsx
        ├── container.ts
        ├── index.ts
        ├── types.ts
        ├── Modal/
        │   ├── index.tsx
        │   └── types.ts
        ├── InputField/
        │   ├── index.tsx
        │   └── types.ts
        └── Table/
            ├── index.tsx
            ├── types.ts
            └── Row/
                ├── index.tsx
                └── types.ts

依存関係

Compound Components導入前の依存関係

After: Compound Components導入後

src/
└── コンポーネントA/
    ├── Modal/
    │   ├── component.tsx
    │   ├── container.ts
    │   ├── index.ts
    │   └── types.ts
    ├── Form/
    │   ├── component.tsx
    │   ├── container.ts
    │   ├── index.ts
    │   ├── provider.tsx
    │   └── types.ts
    ├── InputField/
    │   └── index.tsx
    └── Table/
        ├── Row/
        │   ├── index.tsx
        │   └── types.ts
        ├── component.tsx
        ├── container.ts
        ├── index.ts
        └── types.ts

依存関係

Compound Components導入後の依存関係

Before After
状態管理・通信の責務が分散 通信ロジックをFormに集約し、子コンポーネントは表示専用化
Propsのバケツリレーで管理が煩雑化 ContextAPIを導入し、直接的な状態管理が可能に

Formikの工夫とContextAPI

Compound Components Patternの導入後、Formikの状態管理を改善するためContextAPIを活用しました。

実装例

import React, { createContext, useContext } from 'react';

// Contextの作成
const FieldValueContext = createContext(null);

export const useFieldValue = () => {
  const context = useContext(FieldValueContext);
  if (!context) {
    throw new Error('useFieldValueはFieldValueProvider内で使用してください');
  }
  return context;
};

export const FieldValueProvider = ({ setFieldValue, children }) => {
  return (
    <FieldValueContext.Provider value={setFieldValue}>
      {children}
    </FieldValueContext.Provider>
  );
};

使用例

FormikのsetFieldValueをContext経由で共有することで、子コンポーネント間の状態更新が容易になりました。

export const コンポーネントA() => {
  return (
  <Formik initialValues={initialValues} onSubmit={handleSubmit}>
    {({ setFieldValue }) => (
      <FieldValueProvider setFieldValue={setFieldValue}>
        <CustomFormComponent />
      </FieldValueProvider>
    )}
  </Formik>
  )
}

この工夫により、Compound Components Patternと組み合わせてフォーム状態管理がシンプルかつ柔軟になり、複雑なデータ伝達の課題を解消しました。

導入後に感じたメリット

コードの改善点

  1. 責務の明確化: 通信ロジックをFormコンポーネントに集約し、子コンポーネントは表示専用に設計。

  2. Propsの最適化: ContextAPIの活用により、Propsのバケツリレーを解消。

  3. 状態管理の改善: FormikとCompound Componentsの組み合わせでシンプルな状態管理を実現。

おわりに

本記事では、EditorUにおけるCompound Components Patternの導入事例を紹介しました。コードの保守性や開発効率が大幅に向上し、UI設計が柔軟でシンプルになったと感じています。

複雑なUI設計やPropsのバケツリレーに悩んでいる方は、ぜひCompound Components Patternの導入を検討してみてください。

最後までお読みいただき、ありがとうございました!

「ウィルゲート Advent Calendar 2024(https://adventar.org/calendars/10272)」、翌日ははるみんさんによる「デザインとコーディングの連携をスムーズに!エンジニアが開発で使えるFigma無料版おすすめ機能 」です。乞うご期待!