shadcn/uiに学ぶReactのprops地獄から抜け出すCompound Componentパターン

開発

「このコンポーネント、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が生まれるのか

  1. 要件の追加ラッシュ: 「ここにボタン追加して」「ロゴも出したい」「閉じるボタンも」
  2. 既存 API を壊したくない: 新しい props を足していく方が安全に見える
  3. コンポーネント分割の判断が難しい: どこで切るべきか分からない

この状態は「中間のコンポーネントが直接必要としない 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 SoupCompound 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"
/>

一見シンプルに見えますが、以下の問題が発生します:

  1. カスタマイズの限界: 「Title と Description の間に画像を入れたい」→ props 追加?
  2. 条件分岐の複雑化: 「モバイルでは Footer を非表示」→ どの props で制御?
  3. スタイルの上書き困難: 「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 つあります。

  1. 構造がドメインルールで決まる: 注文サマリーに「商品一覧→小計→合計→確認ボタン」以外の順序はありえない
  2. データ駆動である: API レスポンスをマッピングして表示するため、子コンポーネントを手書きする意味がない
  3. 柔軟性より一貫性が重要: 同じビジネスデータは、どの画面でも同じ見た目で表示されるべき

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 を分割するのも有効です(例: OpenContextToggleContext を分ける)。

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 SoupCompound Component
学習コスト低い(propsを見れば分かる)やや高い(構造の理解が必要)
柔軟性propsで制御可能な範囲のみ配置・順序を自由に変更可能
コード量(利用側)少ないやや多い
型定義肥大化しがちサブコンポーネントごとに分散
拡張時の影響既存コードに影響しやすい影響が小さい
向いている用途ドメインコンポーネントUIコンポーネント

shadcn/ui が使いやすいと感じるのは、このパターンを適切に採用しているから。逆に、独自 UI コンポーネントが使いにくいと感じるなら、Props Soup になっていないか確認してみてください。

判断基準をもう一度:

  • UI コンポーネント + 柔軟性が必要 → Compound Component
  • ビジネスコンポーネント + 構造が固定 → Props Soup で十分
  • 迷ったら → まず Props Soup で始めて、限界を感じたら Compound に移行

唯一の正解はない。でも、判断基準があればチームでの議論がだいぶ楽になるはず。

Hooks時代でもPresenter分離が有効な理由|Storybook・テスト・分業の観点から開発
Thanks for reading!
Reactコンポーネント設計shadcn/uiTypeScriptフロントエンド設計