「このコンポーネント、props が 20 個もあるんだけど…」
コードレビューでこんな指摘をしたこと、ありませんか?あるいは自分で書いていて「props 多すぎない?」と不安になったこと。
この状態を海外では**Props Soup(プロップススープ)**と呼びます。具材(props)を放り込みすぎて、何が何だか分からなくなった状態。対してCompound Componentは、コンポーネントを小さなパーツに分割し、組み合わせて使うパターンです。
実際に shadcn/ui を使ったプロジェクトから、独自 UI コンポーネントを使うプロジェクトに移った経験があります。そこで痛感したのは、UI レベルのコンポーネントは柔軟性が命だということ。その経験をもとに、分岐点を整理しました。
なお、hooks をどこで注入すべきかの記事で扱った「コロケーション vs リフトアップ」とも関連するテーマです。
Reactでhooksをどこで注入すべきか?コロケーション vs リフトアップの判断基準開発Props Soupとは何か
Props Soup は、1 つのコンポーネントに大量の props を詰め込んだ状態を指します。
// Props Soupの例:propsが増え続けるHeader
<Header
title="ダッシュボード"
subtitle="売上レポート"
logoImg="/logo.png"
logoSize="lg"
showBackButton={true}
onBackClick={handleBack}
showCloseButton={true}
onCloseClick={handleClose}
actions={[{ label: '保存', onClick: save }]}
actionsPosition="right"
backgroundColor="#fff"
sticky={true}
borderBottom={true}
// ... まだ続く
/>
なぜProps Soupが生まれるのか
- 要件の追加ラッシュ: 「ここにボタン追加して」「ロゴも出したい」「閉じるボタンも」
- 既存 API を壊したくない: 新しい props を足していく方が安全に見える
- コンポーネント分割の判断が難しい: どこで切るべきか分からない
この状態は「中間のコンポーネントが直接必要としない props でも、何層ものコンポーネントを経由して下に渡される」と表現されます(いわゆる prop drilling)。中間層のコンポーネントが、自分では使わない props をただ下に流すだけになる。これが保守性を下げる原因です。
Compound Componentとは何か
Compound Component は、複数の小さなコンポーネントが協調して 1 つの機能を実現するパターンです。HTML の<select>と<option>の関係に似ています。
// Compound Componentの例:同じHeaderの別表現
<Header>
<Header.Logo src="/logo.png" size="lg" />
<Header.Title>ダッシュボード</Header.Title>
<Header.Subtitle>売上レポート</Header.Subtitle>
<Header.Actions>
<Header.BackButton onClick={handleBack} />
<Header.CloseButton onClick={handleClose} />
<Button onClick={save}>保存</Button>
</Header.Actions>
</Header>
Vercel Academy のドキュメントでは、このパターンについてこう説明しています:
「何十もの props を 1 つのコンポーネントに詰め込むのではなく、複数の協調するコンポーネントに責務を分散させる」
これが Compound Component の本質です。
内部の仕組み
Compound Component は React の Context API を使って状態を共有します。
// 1. Context で状態を管理
interface HeaderContextValue {
sticky: boolean;
variant: 'default' | 'transparent';
}
const HeaderContext = createContext<HeaderContextValue | null>(null);
// 2. 親コンポーネントがProviderとして機能
function Header({ children, sticky = false, variant = 'default' }) {
const contextValue = useMemo(
() => ({ sticky, variant }),
[sticky, variant]
);
return (
<HeaderContext.Provider value={contextValue}>
<header className={cn(styles.header, sticky && styles.sticky)}>
{children}
</header>
</HeaderContext.Provider>
);
}
// 3. 子コンポーネントがContextから値を取得
function HeaderTitle({ children }) {
const context = useContext(HeaderContext);
if (!context) throw new Error('Header.Title must be used within Header');
return <h1 className={styles.title}>{children}</h1>;
}
// 4. サブコンポーネントを親に紐づけ
Header.Title = HeaderTitle;
Header.Logo = HeaderLogo;
Header.Actions = HeaderActions;
// ...
Kent C. Dodds のブログでは、このパターンを「親コンポーネントが Context の値を React ツリー全体に提供する責務を持つ」と説明しています。親が状態を管理し、子がそれを参照する。props を介さない暗黙的な状態共有です。
2つのパターンを比較する
同じ機能を実現する 2 つのアプローチを並べてみましょう。
| 観点 | Props Soup | Compound Component |
|---|---|---|
| API の形 | <Component prop1 prop2 .../> | <Component><Sub/></Component> |
| 柔軟性 | props で制御可能な範囲のみ | 子の配置・順序を自由に変更可能 |
| 型安全性 | props の型定義が肥大化 | 各サブコンポーネントが独自の型を持つ |
| 学習コスト | props を見れば分かる | 構造を理解する必要がある |
| 拡張性 | props 追加 → 既存コードに影響 | サブコンポーネント追加 → 影響小 |
コードの差を見る
Props Soup パターン
// 呼び出し側:何が何を制御するか分かりにくい
<Card
title="記事タイトル"
description="記事の説明文"
image="/image.jpg"
imagePosition="top"
footer={<Button>詳細を見る</Button>}
footerAlign="right"
onClick={handleClick}
hoverable={true}
bordered={true}
/>
Compound Component パターン
// 呼び出し側:構造が視覚的に分かる
<Card hoverable bordered onClick={handleClick}>
<Card.Image src="/image.jpg" position="top" />
<Card.Header>
<Card.Title>記事タイトル</Card.Title>
<Card.Description>記事の説明文</Card.Description>
</Card.Header>
<Card.Footer align="right">
<Button>詳細を見る</Button>
</Card.Footer>
</Card>
shadcn/uiが使いやすい理由
shadcn/ui を使ったことがある人なら、その使いやすさを体感しているはずです。その理由の 1 つが、Compound Component パターンの採用です。
Dialog の例
// shadcn/ui の Dialog
<Dialog>
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>タイトル</DialogTitle>
<DialogDescription>説明文</DialogDescription>
</DialogHeader>
<div>本文</div>
<DialogFooter>
<Button>OK</Button>
</DialogFooter>
</DialogContent>
</Dialog>
これを Props Soup で表現しようとすると:
// Props Soup 版(仮)
<Dialog
trigger={<Button>Open</Button>}
title="タイトル"
description="説明文"
content={<div>本文</div>}
footer={<Button>OK</Button>}
footerAlign="right"
/>
一見シンプルに見えますが、以下の問題が発生します:
- カスタマイズの限界: 「Title と Description の間に画像を入れたい」→ props 追加?
- 条件分岐の複雑化: 「モバイルでは Footer を非表示」→ どの props で制御?
- スタイルの上書き困難: 「Title だけ色を変えたい」→
titleClassNameを追加?
この壁にぶつかりがち。「子の css を上書きするしかない」みたいな状況が多発して、つらみ。
asChildという隠れた立役者
上のコード例で <DialogTrigger asChild> という記述に気づいたでしょうか。この asChild が shadcn/ui の柔軟性を支える重要な仕組みです。
// asChild なし:DialogTrigger が button をレンダリング
<DialogTrigger>Open</DialogTrigger>
// → <button>Open</button>
// asChild あり:子要素をそのまま使う
<DialogTrigger asChild>
<a href="#">Open</a>
</DialogTrigger>
// → <a href="#">Open</a>(DialogTrigger の機能付き)
asChild を使うと、コンポーネントが提供する機能(イベントハンドラ、アクセシビリティ属性など)を、任意の要素に「移植」できます。ボタンではなくリンクにしたい、アイコンだけにしたい、といった要望に props を追加せずに対応できる。
shadcn/ui を1年使ってみて、asChild の柔軟性に何度救われたか分からない。「ここはボタンじゃなくて Next.js の Link にしたい」みたいな場面で、コンポーネントを改造せずに済む。
shadcn/uiの設計思想
shadcn/ui の哲学は「コードを所有し、完全にカスタマイズ可能」というものです。Compound Component パターンは、この思想と相性が良い:
- 必要なパーツだけ使える: Footer が不要なら書かない
- 順序を自由に変更できる: Header を下に持っていくことも可能
- カスタムパーツを差し込める: 標準にない要素も追加できる
- asChild で要素を差し替えられる: button を a に、div を article に
判断基準:どちらを選ぶべきか
設計パターンは「いつ使うか」の判断が重要です。フローチャートを作ったので、参考にどうぞ。
フロントエンドにクリーンアーキテクチャは必要か?判断基準とWeb/モバイルの違い開発そのコンポーネントはUIレベル(ボタン、カード、モーダル等)?
│
├─ Yes → 子の配置を利用者が制御したい?
│ │
│ ├─ Yes → Compound Component
│ │
│ └─ No → 構造が複数パターンある?
│ │
│ ├─ Yes → Compound Component
│ │
│ └─ No → Props Soup でも OK
│
└─ No(ビジネスロジックを含む)→ Props Soup で十分なことが多い
Compound Component を選ぶべきケース
1. 汎用 UI コンポーネント
Tabs、Accordion、Dropdown、Modal など。利用者がレイアウトを制御したいケース。
2. デザインシステムの構築
複数プロジェクトで再利用する場合、柔軟性が重要。
3. 子の配置パターンが複数ある
「Title が上のパターン」「Title が左のパターン」など。
Props Soup で十分なケース
1. ビジネス文脈を含むコンポーネント(ドメインコンポーネント)
React コンポーネントは、その責務によって以下のように分類できます。Martin Fowler も記事でビューロジックと非ビューロジック(ドメインロジック)の分離が重要だと強調しています。
- UIコンポーネント: Button、Card、Modal など。見た目と汎用的なインタラクションを担当
- ドメインコンポーネント: OrderSummary、PaymentMethodSelector、UserPermissionBadge など。特定のビジネスルールやデータ構造に依存
// ドメインコンポーネントの例:Props Soup で OK
<OrderSummary
orderId={order.id}
items={order.items}
total={order.total}
onConfirm={handleConfirm}
/>
ドメインコンポーネントに Compound Component が不向きな理由は 3 つあります。
- 構造がドメインルールで決まる: 注文サマリーに「商品一覧→小計→合計→確認ボタン」以外の順序はありえない
- データ駆動である: API レスポンスをマッピングして表示するため、子コンポーネントを手書きする意味がない
- 柔軟性より一貫性が重要: 同じビジネスデータは、どの画面でも同じ見た目で表示されるべき
TkDodo 氏も自身のブログで、チームが最初は Compound Component 型の Select を実装したが、実際の運用ではほぼすべての使用箇所でマッピングコードを書くことになったため、props ベースの API に移行したと述べています。動的データを扱うコンポーネントでは、Compound のメリットが薄れるのです。
2. シンプルなコンポーネント
props が 5 個以下なら、わざわざ Compound にする必要はない。
3. 1-2 階層の prop drilling のみ
親 → 子、または親 → 子 → 孫くらいの props 渡しなら、普通に props で渡した方がシンプル。Compound Component が解決するのは「5階層も6階層もバケツリレーする」ような状況です。
さらに言えば、Compound Component が活きるのは「レイアウトパターンが複数ある」「動的な状態共有がある」といった要件がある場合。単に props を 1-2 階層渡すだけで、レイアウトも固定なら、わざわざ Context を導入する意味がない。
現場での判断が難しい場面
理屈では分かっていても、実際の開発現場では判断に迷う場面が多い。特にデザインが全て決まっていない段階での UI コンポーネント設計は本当に難しい。
ページごとに少しずつデザインが上がってくる状況で、「今見えているレイアウトだけを考えてシンプルに作る」か「将来のバリエーションを見越して柔軟に作る」か。前者を選ぶと、イレギュラーなレイアウトが登場した途端にそのコンポーネントは使えなくなる。かといって後者を選ぶと、Compound Component の複雑さを抱えることになる。
shadcn/ui を使っていた頃は、この判断を Radix UI がやってくれていた。独自実装になった途端、「この RadioGroup、options 配列で受け取る?それとも Compound にする?」で毎回悩む。
RadioGroupの例:options vs Compound vs 両対応
RadioGroup は、この判断の難しさを象徴するコンポーネントです。
// パターン1: options 配列で受け取る(Props Soup 寄り)
<RadioGroup
options={[
{ value: 'a', label: 'オプションA' },
{ value: 'b', label: 'オプションB' },
]}
value={selected}
onChange={setSelected}
/>
// パターン2: Compound Component
<RadioGroup value={selected} onChange={setSelected}>
<RadioGroup.Item value="a">オプションA</RadioGroup.Item>
<RadioGroup.Item value="b">オプションB</RadioGroup.Item>
</RadioGroup>
// パターン3: 両対応(shadcn/ui の RadioGroup)
// Compound だが、各 Item のレイアウトは自由
<RadioGroup value={selected} onValueChange={setSelected}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="a" id="option-a" />
<Label htmlFor="option-a">オプションA</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="b" id="option-b" />
<Label htmlFor="option-b">オプションB</Label>
</div>
</RadioGroup>
パターン1 は API データをそのままマッピングするときに便利。パターン2 は各項目のレイアウトをカスタマイズしたいときに強い。パターン3 は最も柔軟だけど、利用側のコード量が増える。
結局どれを選ぶかは、そのプロジェクトで RadioGroup がどれだけ多様なレイアウトで使われるかによる。1パターンしかないなら options 配列で十分。3パターン以上あるなら Compound にしないと props が爆発する。
実装時の注意点
Compound Component を採用する場合、いくつかの注意点があります。
1. Context の外で使うとエラーになる
// これはエラー
<Card.Title>タイトル</Card.Title> // Card の外で使用
// useContext が null を返すので、エラーハンドリングが必要
function useCardContext() {
const context = useContext(CardContext);
if (!context) {
throw new Error('Card.* components must be used within <Card>');
}
return context;
}
2. cloneElement 方式の制限
// React.Children.map + cloneElement 方式
// ❌ 直下の子以外には props が渡らない
<Card>
<div>
<Card.Title>ここには渡らない</Card.Title>
</div>
</Card>
patterns.dev では「別のコンポーネントでラップすると機能が失われる」と警告しています。Context 方式を使えばこの問題は回避できます。
もう1つの問題がprops の命名衝突。cloneElement は shallow merge なので、子要素に同名の prop があると上書きされます。
// 親から isActive を渡そうとしている
React.cloneElement(child, { isActive: true })
// でも子がすでに isActive を持っていると...
<Card.Item isActive={false} /> // → false が勝つ(親の値が消える)
3. サブコンポーネントは単独エクスポートしない
// ❌ 単独エクスポートすると、Card なしで使えてしまう
export { CardTitle, CardHeader, CardContent };
// ✅ 親のプロパティとしてのみ公開
export { Card };
// Card.Title, Card.Header として使用
4. Context の再レンダリング問題
Context を使う場合、親の状態が変わると全ての子コンポーネントが再レンダリングされます。
// ❌ 毎レンダリングで新しいオブジェクトが作られる
function Header({ children, sticky }) {
return (
<HeaderContext.Provider value={{ sticky }}>
{children}
</HeaderContext.Provider>
);
}
// ✅ useMemo でメモ化
function Header({ children, sticky }) {
const contextValue = useMemo(() => ({ sticky }), [sticky]);
return (
<HeaderContext.Provider value={contextValue}>
{children}
</HeaderContext.Provider>
);
}
複雑なケースでは、状態ごとに Context を分割するのも有効です(例: OpenContext と ToggleContext を分ける)。
5. Server Components との互換性
React Server Components(RSC)と Compound Component は併用できますが、Provider と Consumer は同じ環境に揃える必要があります。
// ❌ Server Component で Provider、Client Component で Consumer は動かない
// page.tsx (Server Component)
<Tabs defaultValue="a"> {/* Provider */}
<TabsContent value="a">...</TabsContent> {/* Consumer */}
</Tabs>
// ✅ 全体を Client Component にする
'use client'
export function TabsExample() {
return (
<Tabs defaultValue="a">
<TabsContent value="a">...</TabsContent>
</Tabs>
);
}
shadcn/ui のコンポーネントには 'use client' が付いているのはこのためです。
まとめ
Props Soup と Compound Component、どちらも正解。ただ、UI レベルのコンポーネントでは Compound Component の柔軟性が威力を発揮するのは確かです。
| 観点 | Props Soup | Compound Component |
|---|---|---|
| 学習コスト | 低い(propsを見れば分かる) | やや高い(構造の理解が必要) |
| 柔軟性 | propsで制御可能な範囲のみ | 配置・順序を自由に変更可能 |
| コード量(利用側) | 少ない | やや多い |
| 型定義 | 肥大化しがち | サブコンポーネントごとに分散 |
| 拡張時の影響 | 既存コードに影響しやすい | 影響が小さい |
| 向いている用途 | ドメインコンポーネント | UIコンポーネント |
shadcn/ui が使いやすいと感じるのは、このパターンを適切に採用しているから。逆に、独自 UI コンポーネントが使いにくいと感じるなら、Props Soup になっていないか確認してみてください。
判断基準をもう一度:
- UI コンポーネント + 柔軟性が必要 → Compound Component
- ビジネスコンポーネント + 構造が固定 → Props Soup で十分
- 迷ったら → まず Props Soup で始めて、限界を感じたら Compound に移行
唯一の正解はない。でも、判断基準があればチームでの議論がだいぶ楽になるはず。
Hooks時代でもPresenter分離が有効な理由|Storybook・テスト・分業の観点から開発