Reactコンポーネント設計の分岐点 ── Configuration と Composition、どちらを選ぶか

はじめに

最近、お客様ポータルのReact化を進める中で、UIライブラリーもPHP+SCSSの既存の仕組みからReact/TypeScriptへ再構築しています。デザイントークンをコンポーネントに落とし込む作業を続ける中で、何度もぶつかった問いがあります。

このコンポーネントのAPIは、propsで制御すべきか、childrenで組み立てさせるべきか。

最初は「propsが増えすぎたらCompositionに切り替える」くらいの認識でした。しかし構築を進めるうちに、判断基準はもっと構造的なものだとわかってきました。

この記事では、構築過程で見えてきたConfigurationとCompositionの使い分けを整理します。

propsが20個を超えたとき

Design Systemのコンポーネントを作っていると、ある日ふと気づきます。Buttonの型定義を開いたら、propsが20個を超えています。

variant, size, icon, iconPosition, loading, disabled, fullWidth, as, href, target… まだ続きます。

新しい要件が来るたびに isHoge というbooleanが1つ増え、コンポーネント内部のif文がまた1つ増えます。これは「Configuration」アプローチの典型的な末路です。

一方で、「全部compositionにすればいい」と振り切ると、利用側のコードが毎回20行になり、UIの一貫性が崩れていく——設計を検討する中で、そのリスクも繰り返し見えてきました。

Configuration:propsで制御する世界

Configurationは、1つのコンポーネントに対してpropsでふるまいを指定するアプローチです。使う側はpropsを渡すだけでよく、内部の構造を意識する必要がありません。

// Configuration アプローチ
<Alert
  variant="warning"
  title="接続エラー"
  description="サーバーとの接続が切断されました。再試行してください。"
  icon={<WarningIcon />}
  closable
  onClose={handleClose}
  action={{ label: "再試行", onClick: handleRetry }}
/>

利用側のコードは簡潔で、propとして公開されていないカスタマイズは物理的にできません。意図しないUIの逸脱が起きにくくなります。

Configurationが破綻するとき

要件が増えるたびにpropsが増殖します。しかし、propsの数自体は問題の本質ではありません

たとえばヘッダーコンポーネントで logoSrc, logoAlt, logoHref, userName, memberId, onLogout と10個以上のpropsがあっても、各propsは互いに独立しており、内部の条件分岐も単純な有無チェックだけで済みます。この場合、Configurationは健全に機能します。

破綻の本当のシグナルは、props間の依存関係です。

// これが危険信号
type AlertProps = {
  closable?: boolean;
  onClose?: () => void;          // closable=true のときだけ意味がある
  showTimestamp?: boolean;
  timestamp?: Date;              // showTimestamp=true のときだけ必要
  showProgress?: boolean;
  progressValue?: number;        // showProgress=true のときだけ必要
  progressLabel?: string;        // showProgress=true のときだけ必要
};

条件付きpropsが3つ以上出てきたら、コンポーネントは「設定値の組み合わせ」ではなく「構造の選択」を迫られています。それはConfigurationの守備範囲を超えています。

コンポーネント内部も、この依存関係に比例して条件分岐の森になります。

const Alert = (props: AlertProps) => {
  return (
    <div className={clsx(styles.root, props.bordered && styles.bordered, props.compact && styles.compact)}>
      {props.icon && <div className={styles.icon}>{props.icon}</div>}
      <div className={styles.content}>
        {props.title && <div className={styles.title}>{props.title}</div>}
        {props.description && <p className={styles.description}>{props.description}</p>}
        {props.showTimestamp && props.timestamp && (
          <time className={styles.timestamp}>{formatDate(props.timestamp)}</time>
        )}
      </div>
      {props.closable && (
        <button className={styles.close} onClick={props.onClose}>
          <CloseIcon />
        </button>
      )}
      {props.customFooter && <div className={styles.footer}>{props.customFooter}</div>}
    </div>
  );
};

CVAでConfigurationを延命する

Configurationが「破綻する」ラインを引き上げるツールがあります。class-variance-authority(CVA)です。今回の構築でも、ButtonやInputGroupなど複数のコンポーネントで採用しています。

import { cva, type VariantProps } from 'class-variance-authority';

const buttonVariants = cva(styles.root, {
  variants: {
    variant: {
      primary:   styles.primary,
      secondary: styles.secondary,
      danger:    styles.danger,
      ghost:     styles.ghost,
    },
    size: {
      sm: styles.sm,
      md: styles.md,
      lg: styles.lg,
    },
  },
  defaultVariants: {
    variant: 'primary',
    size: 'md',
  },
});

type ButtonProps = VariantProps<typeof buttonVariants> & { /* ... */ };

CVAを使うとvariant関連のif文がゼロになります。VariantProps で型も自動生成されるので、propsの型定義とスタイルの定義が一致することが保証されます。

ただし、CVAが解決するのは列挙可能なvariantの管理であって、前述の「条件付きprops」の問題ではありません。CVAはConfigurationを延命しますが、Compositionが必要なタイミングを変えるわけではありません。

Composition:パーツを組み立てる世界

Compositionは、小さなパーツを利用者が自由に組み合わせるアプローチです。

// Composition アプローチ
<Alert variant="warning">
  <Alert.Icon>
    <WarningIcon />
  </Alert.Icon>
  <Alert.Content>
    <Alert.Title>接続エラー</Alert.Title>
    <Alert.Description>
      サーバーとの接続が切断されました。再試行してください。
    </Alert.Description>
  </Alert.Content>
  <Alert.Actions>
    <Button onClick={handleRetry}>再試行</Button>
  </Alert.Actions>
  <Alert.CloseButton onClick={handleClose} />
</Alert>

利用側のコードは長くなりますが、構造が明示的になります。新しい要件が来ても、既存のコンポーネントを変更せずに対応できることが多いです。

// プログレスバーを追加 → 既存のAlertを一切変更せず
<Alert variant="warning">
  <Alert.Icon><WarningIcon /></Alert.Icon>
  <Alert.Content>
    <Alert.Title>アップロード中</Alert.Title>
    <Alert.Description>ファイルを処理しています...</Alert.Description>
    <ProgressBar value={65} />
  </Alert.Content>
  <Alert.CloseButton onClick={handleClose} />
</Alert>

Compositionが破綻するとき

自由度が高いということは、利用者がUIを壊せるということでもあります。

// 開発者Aの書き方
<Alert variant="error">
  <Alert.Content>
    <Alert.Title>エラー</Alert.Title>
  </Alert.Content>
  <Alert.CloseButton onClick={handleClose} />
</Alert>

// 開発者Bの書き方(同じ意図なのに構造が違う)
<Alert variant="error">
  <Alert.CloseButton onClick={handleClose} />
  <div className="custom-wrapper">
    <Alert.Title>エラー</Alert.Title>
    <p>独自のスタイルを当てたかった</p>
  </div>
</Alert>

構築中の今はまだ利用者が自分だけですが、今後チームに展開していくことを考えると、「正しい組み合わせ方」がドキュメントだけでは伝わらなくなるリスクは設計段階から意識しておく必要があります。

見落とされがちな2つの中間パターン

ConfigurationとCompositionは「propsで全部渡すか、childrenで全部組むか」の二択として語られがちです。しかし実際には、この二極の間にある中間パターンが頻繁に必要になります。

パターンA:ReactNode Slot Props

compound componentを作るほどではないが、特定の位置に自由な要素を差し込みたい。この時に使うのが ReactNode 型のpropsです。今回の構築でも、サービス選択パネルでこのパターンを採用しました。

type SelectablePanelProps = {
  variant: 'service' | 'area';
  control?: ReactNode;    // 左上に置くCheckboxやRadio
  thumbnail?: ReactNode;  // アイコン画像
  title: ReactNode;       // パネルタイトル
  desc?: ReactNode;       // 説明文
  badge?: ReactNode;      // 外側左上のバッジ
};
// 利用側:構造は固定、中身だけ自由
<SelectablePanel
  variant="service"
  control={<Checkbox />}
  thumbnail={<img src="/icon.svg" alt="" />}
  title="光回線プラン"
  desc="最大1Gbpsの高速回線"
  badge={<OuterBadge>おすすめ</OuterBadge>}
/>

レイアウト構造はコンポーネントが決定し、各スロットの中身は利用者が決めます。利用者がパーツの順序や構造を壊せないので、Configuration的な安全性を保てます。

「stringで十分なpropsはstring、構造が欲しい位置だけReactNode」と使い分けることで、propsの爆発もcompound componentの複雑さも回避できます。

パターンB:データ駆動 Composition

JSX childrenの代わりに構造化データの配列で内容を渡すパターンです。今回の構築では、確認画面のテーブルやお申し込みフローのステップ表示で使っています。

<Table
  rows={[
    { title: "プラン名", content: "光回線 1Gbps" },
    { title: "月額料金", content: "4,400円(税込)" },
    { title: "契約期間", content: "2年" },
  ]}
/>

<FlowStepList
  steps={[
    { number: 1, title: "お申し込み", body: "フォームに必要事項を入力" },
    { number: 2, title: "工事日調整", body: "担当者よりご連絡" },
    { number: 3, title: "開通",      body: "工事完了後すぐ利用可能" },
  ]}
/>

テーブルやリストのような繰り返し構造で、各アイテムの形が一定の場合、JSX childrenよりもデータ配列の方が扱いやすくなります。利用側は <tr>, <td>, <li> のマークアップ構造を意識する必要がありません。

有効な条件:

  • 繰り返し構造(リスト、テーブル、ステップ表示)
  • 各アイテムの形が同じ型で表現できる
  • マークアップ構造がコンポーネント内部に閉じるべき

逆に、アイテムごとに構造が異なる場合や、カスタムの区切り・グルーピングが必要な場合は、JSX childrenの方が適しています。

判断基準:どう選ぶか

4つのパターンを見てきました。ここで判断軸を整理します。

状況 適したパターン
列挙可能なvariant Configuration(CVAで管理)
同じ形のデータの繰り返し データ駆動(配列props)
構造は固定、中身だけ変わる ReactNode Slot Props
構造自体が利用者次第 Compound Components
大半は定形、一部カスタム Dual Mode(Config + Composition)

1. props間の依存関係を数える

props数ではなく依存関係が破綻のシグナルです。

独立したpropsがいくら増えても、内部は単純な有無チェックの羅列で済みます。一方、showXxValue のような条件付きpropsが増えると、型レベルでは表現しきれない暗黙の制約が生まれます。

目安:条件付きpropsのペアが3つ以上になったら、その塊をCompositionに切り出すことを検討してください。

2. バリエーションの性質を見る

バリエーションが有限で列挙可能なら → Configuration + CVA。

バリエーションが組み合わせ的に増殖するなら:

  • 構造が固定で中身だけ変わる → ReactNode Slot Props
  • 構造自体を利用者が決める → Compound Components

3. 繰り返しかどうかを見る

同じ形のアイテムが繰り返されるなら、データ駆動を検討します。JSX childrenだと <Item> を10個並べるだけのボイラープレートになります。データ配列なら、表示に必要な情報を1つのオブジェクト配列にまとめて渡すだけで済みます。

4. 一貫性 vs 柔軟性

  • UIが崩れることが致命的(金融系、医療系、toB SaaS)→ Configurationで逸脱を防ぐ
  • 頻繁にUI実験やカスタマイズが必要 → Compositionで柔軟性を確保

第三の選択肢:Compound Components + Sensible Defaults

構築を進める中で、設計上の選択肢として検討しているのが「デフォルトではConfigurationのように使えて、必要に応じてCompositionで拡張できる」設計です。

// 基本的な使い方(Configuration的)
<Alert variant="warning" title="接続エラー" description="再試行してください。" />

// カスタマイズが必要な場合(Composition的)
<Alert variant="warning">
  <Alert.Icon><CustomIcon /></Alert.Icon>
  <Alert.Content>
    <Alert.Title>接続エラー</Alert.Title>
    <Alert.Description>
      サーバーとの接続が切断されました。
      <Link href="/status">ステータスページ</Link>を確認してください。
    </Alert.Description>
    <ProgressBar value={retryProgress} />
  </Alert.Content>
  <Alert.Actions>
    <Button onClick={handleRetry}>再試行</Button>
    <Button variant="ghost" onClick={handleDetail}>詳細</Button>
  </Alert.Actions>
</Alert>

実装のポイントは、propsとchildrenの共存です。

type AlertProps = {
  variant: 'info' | 'warning' | 'error' | 'success';
  title?: string;
  description?: string;
  children?: React.ReactNode;
};

const Alert = ({ variant, title, description, children }: AlertProps) => {
  const isComposed = children != null;

  return (
    <AlertContext.Provider value={{ variant }}>
      <div className={clsx(styles.root, styles[variant])}>
        {isComposed ? (
          children
        ) : (
          <>
            <Alert.Icon><DefaultIcon variant={variant} /></Alert.Icon>
            <Alert.Content>
              {title && <Alert.Title>{title}</Alert.Title>}
              {description && <Alert.Description>{description}</Alert.Description>}
            </Alert.Content>
          </>
        )}
      </div>
    </AlertContext.Provider>
  );
};

大半のユースケースはpropsだけで済み、特殊なケースではCompositionで対応できます。

注意点:

  • children の有無でモードが切り替わるルールは、ドキュメントなしでは伝わりません
  • Configurationモードの内部実装でCompositionモードと同じサブコンポーネントを使い、両モードの出力を一致させることが重要です

Configurationの健全性チェック

状態 判断
条件付きpropsのペアが3つ以上 Compositionへの分解を検討
propsが互いに独立 数が多くてもConfigurationで問題ない
「このpropは別のpropがtrueのときだけ使う」という説明が必要 依存関係が生まれている

まとめ

Configurationは「正しい使い方を強制する」思想。Compositionは「正しい使い方を信頼する」思想です。どちらが優れているかではなく、状況に応じて配分を決める問題です。

ただし、この二択だけで語ると中間地帯を見落とします。

  • ReactNode Slot Props — 構造の安全性と中身の柔軟性を両立
  • データ駆動 — 繰り返し構造の簡潔な表現
  • CVA — Configurationの寿命を延ばす

これらの選択肢を持っておくことで、「propsが膨らんできたからcompound componentに書き直そう」という過剰反応を避けられます。

コンポーネント設計に唯一の正解はありません。ただ、何が破綻のシグナルで、それぞれのパターンが何を解決するかを理解していれば、設計判断の精度は確実に上がります。構築はまだ続いていますが、ここまでに得た判断基準は、今後コンポーネントが増えていく中でも軸になると考えています。

この記事を気に入ったら

この記事を書いた人

みんみん

みんみん

この人が書いた記事を見る >>