🧩

React Compound Componentパターンの使いどころ

に公開

はじめに

Reactで複雑なUIコンポーネント(Select、Tabs、Accordion、Modalなど)を作成する際、大量のpropsを渡す「Props地獄」に陥ることがあります。

<Select
  options={options}
  value={value}
  onChange={setValue}
  placeholder="選択してください"
  renderOption={(option) => <CustomOption {...option} />}
  renderHeader={(selected) => <Header selected={selected} />}
  onOpen={() => {}}
  onClose={() => {}}
  // ...さらに多くのprops
/>

このようなAPIは認知負荷が高く、想定外のカスタマイズが困難です。

Compound Componentパターンは、親コンポーネントが暗黙的な状態を管理し、子コンポーネントがその状態をContext経由で共有する手法です。HTMLの<select><option>の関係に似ています。

本記事では、Compound Componentパターンの構造と適用判断基準を解説し、Radix UIでの実践的な実装についても考察します。


第1章:このパターンが解決する問題

Props地獄(Prop Soup)

複雑なコンポーネントを作ると、大量のpropsが必要になる問題があります:

  • カスタムレンダリング用のprops
  • イベントハンドラ用のprops
  • 表示制御用のprops

これらが積み重なると、以下の問題が生じます。

1. 認知負荷が高い

どのpropsが何を制御するか把握しづらくなります。

2. 柔軟性が低い

想定外のカスタマイズが困難で、新しい要件のたびにpropsを追加する必要があります。

3. 型定義が複雑

オプショナルなpropsの組み合わせが爆発的に増えます。

Compound Componentによる解決策

Compound Componentは「親が状態を管理し、子がContextで共有する」アプローチです。

<!-- HTMLのネイティブCompound Component -->
<select>
  <option value="1">Option 1</option>
  <option value="2">Option 2</option>
</select>

<select><option>は単独では意味を成しませんが、組み合わせることで完全な機能を提供します。クリックした<option><select>が自動的に認識する—この「暗黙的な状態共有」がCompound Componentの本質です。

// Props地獄
<Select
  options={options}
  value={value}
  onChange={setValue}
  renderOption={(option) => <CustomOption {...option} />}
/>

// Compound Component
<Select value={value} onChange={setValue}>
  <Select.Trigger>選択してください</Select.Trigger>
  <Select.Content>
    <Select.Item value="1">Option 1</Select.Item>
    <Select.Item value="2">Option 2</Select.Item>
  </Select.Content>
</Select>

子コンポーネントの配置やカスタマイズが自由になり、LEGOブロックのように組み合わせられます。


第2章:パターンの構造と実装

基本的なCompound Componentの形

Compound Componentは、Context作成、親コンポーネント、子コンポーネントの3つで構成されます。

import { createContext, useContext, useState, useCallback, useMemo, ReactNode } from 'react';

// 1. Contextを作成
interface FlyOutContextType {
  open: boolean;
  toggle: () => void;
  value: string;
  setValue: (value: string) => void;
}

const FlyOutContext = createContext<FlyOutContextType | null>(null);

// 2. カスタムフック:Context取得を簡略化
function useFlyOutContext() {
  const context = useContext(FlyOutContext);
  if (!context) {
    throw new Error('FlyOut compound components must be used within FlyOut');
  }
  return context;
}

// 3. 親コンポーネント:状態を管理しContextで提供
export function FlyOut({ children }: { children: ReactNode }) {
  const [open, setOpen] = useState(false);
  const [value, setValue] = useState('');
  const toggle = useCallback(() => setOpen((state) => !state), []);

  // Context値をメモ化して不要な再レンダリングを防止
  const contextValue = useMemo(
    () => ({ open, toggle, value, setValue }),
    [open, toggle, value]
  );

  return (
    <FlyOutContext.Provider value={contextValue}>
      <div className="flyout">{children}</div>
    </FlyOutContext.Provider>
  );
}

// 4. 子コンポーネント:Contextから必要な値のみ取得
function Toggle({ children }: { children: ReactNode }) {
  const { toggle } = useFlyOutContext();
  return <div onClick={toggle}>{children}</div>;
}

function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
  const { value, setValue } = useFlyOutContext();
  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
      {...props}
    />
  );
}

function List({ children }: { children: ReactNode }) {
  const { open } = useFlyOutContext();
  return open ? <ul className="flyout-list">{children}</ul> : null;
}

function ListItem({ children, value }: { children: ReactNode; value: string }) {
  const { setValue } = useFlyOutContext();
  return (
    <li onMouseDown={() => setValue(value)}>
      {children}
    </li>
  );
}

// 5. サブコンポーネントを親に紐付け
FlyOut.Toggle = Toggle;
FlyOut.Input = Input;
FlyOut.List = List;
FlyOut.ListItem = ListItem;

使用例

import { FlyOut } from './FlyOut';

export default function SearchInput() {
  return (
    <FlyOut>
      <FlyOut.Toggle>
        <FlyOut.Input placeholder="住所を入力" />
      </FlyOut.Toggle>
      <FlyOut.List>
        <FlyOut.ListItem value="東京">東京</FlyOut.ListItem>
        <FlyOut.ListItem value="大阪">大阪</FlyOut.ListItem>
        <FlyOut.ListItem value="名古屋">名古屋</FlyOut.ListItem>
      </FlyOut.List>
    </FlyOut>
  );
}

具体例:Modal

Props地獄になりがちなModalをCompound Componentで設計してみましょう。

Before: Props地獄

<Modal
  isOpen={isOpen}
  onClose={handleClose}
  title="確認"
  showCloseButton={true}
  body="この操作を実行しますか?"
  footer={
    <>
      <Button onClick={handleCancel}>キャンセル</Button>
      <Button onClick={handleConfirm}>確認</Button>
    </>
  }
  size="medium"
/>

After: Compound Component

<Modal isOpen={isOpen} onClose={handleClose}>
  <Modal.Header>
    <Modal.Title>確認</Modal.Title>
    <Modal.CloseButton />
  </Modal.Header>
  <Modal.Body>
    この操作を実行しますか?
  </Modal.Body>
  <Modal.Footer>
    <Button onClick={handleCancel}>キャンセル</Button>
    <Button onClick={handleConfirm}>確認</Button>
  </Modal.Footer>
</Modal>

Compound Component適用による効果

柔軟性の獲得

  • Headerの順序を変更可能(CloseButtonを左に配置するなど)
  • Bodyに任意のコンテンツを配置可能
  • Footerを省略可能

関心の分離

  • Modal(親):状態管理、オーバーレイ制御
  • Modal.Header(子):ヘッダー領域のレイアウトのみ
  • Modal.CloseButton(子):閉じるアクションのみ(onCloseはContextから取得)

第3章:適用場面の判断基準

適用すべきとき

条件 理由
関連するコンポーネントグループ(Tabs、Accordion、Dropdown、Modal) 状態を暗黙的に共有できる
子コンポーネントの配置を利用者が制御したい 柔軟なAPIを提供できる
デザインシステム構築 再利用可能なコンポーネントライブラリに適している

適用を避けるべきとき

条件 理由
シンプルなコンポーネント 過剰設計になる
子の構造が固定されている 柔軟性が不要なら複雑さが増すだけ
1〜2階層程度のprop drilling Contextを使うほどではない

判断の実例

次のButtonコンポーネントにCompound Componentパターンを適用すべきでしょうか?

<Button icon={<PlusIcon />}>追加</Button>

答え:適用しなくてよい

子の構造に柔軟性が不要で、propsで十分に表現できます。以下のように書く必要はありません:

// 過剰
<Button>
  <Button.Icon><PlusIcon /></Button.Icon>
  <Button.Text>追加</Button.Text>
</Button>

第4章:よくある失敗パターン

ケース1:サブコンポーネントを単独でエクスポート

// 悪い例:ModalFooterを単独でエクスポート
export { ModalFooter } from './modal/ModalFooter';

// 利用者が誤った使い方をする
<div>
  <ModalFooter>  {/* Modalの外で使用 - Contextにアクセスできない */}
    <Button>送信</Button>
  </ModalFooter>
</div>

問題点:サブコンポーネントは親の文脈でのみ意味を持ちます。単独でエクスポートすると、Contextにアクセスできずエラーになります。

改善案:サブコンポーネントは親コンポーネントのプロパティとしてのみ公開します(Modal.Footer)。

ケース2:Contextに過剰な情報を持たせる

// 悪い例:大量の状態を1つのContextに詰め込む
const ModalContext = createContext();

function Modal({ children }) {
  const [isOpen, setIsOpen] = useState(false);
  const [title, setTitle] = useState('');
  const [size, setSize] = useState('medium');
  const [animation, setAnimation] = useState('fade');
  // ...大量の状態

  return (
    <ModalContext.Provider value={{
      isOpen, setIsOpen, title, setTitle, size, setSize, animation, setAnimation
    }}>
      {children}
    </ModalContext.Provider>
  );
}

問題点:毎レンダリングで新しいオブジェクトが作成され、全消費者が再レンダリングされます。

改善案

  • Context値をuseMemoでメモ化
  • 状態用と更新関数用でContextを分割
  • 本当に共有が必要な値のみContextに含める

ケース3:すべてをCompound Componentにしようとする

// 悪い例:シンプルなコンポーネントにも適用
<Card>
  <Card.Header>
    <Card.Title>タイトル</Card.Title>
  </Card.Header>
  <Card.Body>本文</Card.Body>
</Card>

// これで十分
<Card title="タイトル">本文</Card>

問題点:子の構造に柔軟性が不要な場合、複雑さが増すだけです。

判断基準:「子の構造を利用者が変更したいか」を問い、2つ以上該当する場合にのみ検討します。


第5章:現代のReactにおける位置づけ

Radix UI:Compound Componentの模範実装

Radix UIは、Compound Componentパターンを全面的に採用したアンスタイルドコンポーネントライブラリです。

import * as Dialog from '@radix-ui/react-dialog';

<Dialog.Root>
  <Dialog.Trigger>開く</Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay />
    <Dialog.Content>
      <Dialog.Title>タイトル</Dialog.Title>
      <Dialog.Description>説明文</Dialog.Description>
      <Dialog.Close>閉じる</Dialog.Close>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

Radix UIの設計思想

設計思想 説明
Composition over Configuration propsで設定するのではなく、パーツを組み合わせて構築
Unstyled スタイルを持たないため、どんなCSS手法でもカスタマイズ可能
Accessibility Built-in WAI-ARIA準拠、キーボードナビゲーション、フォーカス管理を内包

実践的な判断:最初からCompound Componentにしない

Compound Componentパターンは強力ですが、最初から採用する必要はありません

1. まず普通のpropsで実装する
2. 使い手(自分含む)がpropsの多さに苦しみ始めたら検討
3. 「子の配置を変えたい」「一部を省略したい」という要求が出たら適用

最初からCompound Componentで設計すると、不要な複雑さを抱え込む可能性があります。リファクタリングのタイミングで導入するのが現実的です。


おわりに

Compound Componentパターンは、親コンポーネントが暗黙的な状態を管理し、子コンポーネントがContextを通じて共有する手法です。Props地獄を解消し、LEGOブロックのように組み合わせられる柔軟なAPIを提供します。

重要なのは、すべてのコンポーネントに適用しないことです。判断基準は「子の構造に柔軟性が必要か」であり、シンプルなコンポーネントにはpropsで十分です。

複雑なUIコンポーネント(Dialog、Tabs、Accordion、Dropdownなど)を作る場合は、Radix UIやshadcn/uiの利用を検討してください。アクセシビリティを維持しながら、Compound Componentパターンのベストプラクティスを学べます。


参考文献

書籍

技術記事

公式ドキュメント

  • Radix UI - Compound Componentパターンを採用したアンスタイルドコンポーネントライブラリ。
  • shadcn/ui - Radix UIをベースにしたスタイル済みコンポーネント集。

Discussion