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パターンのベストプラクティスを学べます。
参考文献
書籍
- わかる!ソフトウェア設計トレーニング - 足利聡太著(進捗ゼミナール, 2025)。本記事の元となった教材で、設計パターンの判断基準について詳しく解説されています。
技術記事
- JavaScript Patterns - Compound Pattern - Lydia Hallie著。Compound Componentパターンの詳細な解説と実装例について説明されています。
Discussion