🧩

Compound Componentsで実現する「引き算のUI設計」

に公開2

はじめに

isEditingisThreadshowFooterhideActions...」

プロダクトが成長するにつれ、コンポーネントのPropsは肥大化します。気づけば15個以上のBoolean Propsが並び、内部は条件分岐の嵐——典型的なアンチパターンです。

この問題に対して、Fernando Rojo氏がReact Universe Conf 2025で発表した「Composition Is All You Need」[1]が参考になります。Slackのメッセージ入力欄を題材に、Kent C. Dodds氏が提唱してきたCompound Componentsパターンの有効性を実演しています。

Compound Componentsは、状態管理とアクセシビリティのロジックを親コンポーネントが担い、子コンポーネントは見た目のレンダリングに集中することで、コンポーネントをシンプルかつ堅牢に保ちます。

本記事では、Compound Componentsの本質と、Boolean Propsの乱用を解決する仕組み、そして拡張性を実現する設計パターンを整理します。

Boolean Propsの乱用による問題

典型的な例を見てみましょう。

// ❌ 15個のBoolean Propsで制御
<MessageComposer
  isThread={true}
  isDM={false}
  isEditing={false}
  isForwarding={false}
  showMentions={true}
  showEmoji={true}
  showAttachments={true}
  showGiphy={false}
  hideFooter={false}
  disableSend={false}
  allowSchedule={true}
  showCharCount={false}
  maxLength={4000}
  placeholder="メッセージを入力..."
  onSubmit={handleSubmit}
/>

内部実装は条件分岐の爆発です。

// コンポーネント内部は条件分岐の嵐
function MessageComposer({
  isThread,
  isDM,
  isEditing,
  showMentions,
  showEmoji,
  // ... 省略
}) {
  return (
    <div>
      {!isEditing && !isForwarding && showMentions && <MentionButton />}
      {showEmoji && !isDM && <EmojiPicker />}
      {showAttachments && !isThread && <AttachmentButton />}
      {/* 他にもたくさん条件の組み合わせ...🤯 */}
    </div>
  )
}

Fernando Rojo氏はこれを「Monolith(一枚岩)」と呼び、以下の問題を指摘しています[1:1]

  • Booleanの組み合わせは2^n通りになるため、テストが困難になる(5個で32通り、10個で1024通り)
  • ある条件の修正が別の条件に影響し、バグの温床になる
  • コンテキストが複雑すぎてAIコード生成ツールすら正しく理解できない

TkDodo氏(TanStack Queryのメインコントリビューター)も、Sentryのデザインシステム構築経験をもとに「Have props, but not too many. Avoid booleans.」(Propsは必要なだけに。Booleanは避けよ)という原則を掲げています[2]

Compound Componentsの本質

最も身近な例:HTMLの<select>

<select>
  <option value="apple">りんご</option>
  <option value="orange">みかん</option>
</select>
  • 状態(選択値)は<select>が管理
  • <option>valueだけを持ち、選択状態は親に任せて暗黙的に連携
  • アクセシビリティ(矢印キー操作やスクリーンリーダーへの情報提供など)も自動で付与

Compound Components(複合コンポーネント)は、この関係をReactで再現するパターンです。Kent C. Dodds氏の定義[3]を要約すると、「完全なUIを形成するコンポーネント群で、prop drilling(親から子、孫へとpropsを延々と受け渡すこと)なしに状態を共有できる」パターンです。

CompositionとCompound Componentsの違い

本記事では「Composition」と「Compound Components」という2つの用語が登場します。

Composition(合成)はReactの基本的なUI構築アプローチで、コンポーネントをchildrenやpropsとして組み合わせる考え方全般を指します。一方、Compound Components(複合コンポーネント)はCompositionを土台にした具体的なデザインパターンで、親子コンポーネントがContext APIを通じて暗黙的に状態を共有する点が特徴です。

Boolean Propsからの脱却

先ほどのMessageComposerをCompound Componentsで書き換えてみましょう。

// ✅ Boolean Propsが消えた
<Composer.Root onSubmit={handleSubmit}>
  <Composer.Input placeholder="メッセージを入力..." maxLength={4000} />

  <Composer.Actions>
    <Composer.MentionButton />
    <Composer.EmojiButton />
    <Composer.AttachmentButton />
  </Composer.Actions>

  <Composer.Footer>
    <Composer.CharCount />
    <Composer.ScheduleButton />
    <Composer.SendButton />
  </Composer.Footer>
</Composer.Root>

スレッド返信で添付ファイルを無効にしたい場合は?

// スレッド返信用:AttachmentButtonを書かないだけ
<Composer.Root onSubmit={handleSubmit}>
  <Composer.Input placeholder="返信を入力..." />

  <Composer.Actions>
    <Composer.MentionButton />
    <Composer.EmojiButton />
    {/* AttachmentButton は書かない = 存在しない */}
  </Composer.Actions>

  <Composer.SendButton />
</Composer.Root>

showAttachments={false} のようにPropsで機能の表示/非表示を指示するのではなく、JSXに書かないことで不要な機能を除外します。

これは設計原則でいう**Inversion of Control(制御の反転)**です。Monolithコンポーネントでは、コンポーネント内部が「何をどう表示するか」を決定していました。Compound Componentsでは、その決定権を利用者(親)に委ねます。コンポーネントは「どう動くか」のロジックだけを提供し、「何を表示するか」は利用者がJSXで宣言します。

いつCompound Componentsを使うべきか

Fernando Rojo氏の講演では、「親から渡すBooleanで子コンポーネントの描画ツリーを制御しようとしているなら、それはCompositionを使うべき合図」だと述べられています[1:2]

具体的には、以下のような状況でCompound Componentsの採用を検討してください。

微妙に異なるバリエーションが多数存在するとき

Slackのメッセージ入力欄のように、基本機能は同じでも使われる場所によって微妙にUIや挙動が異なるケースです。

  • チャンネルへの投稿(通常)
  • スレッドへの返信(「チャンネルにも送信」チェックボックスが必要)
  • メッセージの編集(添付ファイルボタンが不要、送信ボタンが「保存」に変わる)
  • メッセージの転送(入力欄の外に送信ボタンがある)

すべてを一つのコンポーネントで処理しようとせず、Composer.RootComposer.InputComposer.Footerのように部品化し、ユースケースごとに必要な部品だけを組み合わせて構成します。

UIの一部を自由にカスタマイズしたいとき

共通化された配列や設定オブジェクトでUIを定義していると、例外的な構造への対応が難しくなります。

たとえば、アコーディオンを配列で定義している場合を考えます。「2番目と3番目の間にセパレーターを入れたい」という要望が出ると、以下のようなハック的なコードが必要になります。

// ❌ 配列ベースの実装:ハックが必要
<Accordion
  items={[
    { label: 'One', content: '...' },
    { label: 'Two', content: '...' },
    { label: '---' },  // ← セパレーター用のハック
    { label: 'Three', content: '...' },
  ]}
/>

// 内部実装も条件分岐が増える
{items.map((item, index) =>
  item.label === '---' ? <hr /> : <AccordionItem {...item} />
)}

Compound Componentsであれば、共通部分は再利用しつつ、特定の要素だけを独自の実装に差し替える「逃げ道(Escape Hatch)」を簡単に作れます。

// ✅ Compound Components:自由に構造を制御
<Accordion>
  <Accordion.Item label="One" content="..." />
  <Accordion.Item label="Two" content="..." />
  <hr />  {/* 任意の要素を自由に挿入 */}
  <Accordion.Item label="Three" content="..." />
</Accordion>

状態管理のロジックを分離したいとき

見た目は同じでも、裏側のデータの扱いが全く異なる場合があります。

通常のチャットでは入力内容がサーバーと同期され、デバイス間で共有されます(Global State)。一方、転送時のメッセージはモーダル内で完結し、閉じれば消える一時的な状態です(Local State)。

UIコンポーネントはContextからデータを受け取るだけに留め、親となるProviderを差し替えるだけで、UIコードを変更せずにロジックを切り替えられます。

コンポーネントツリーの「外側」と連携が必要なとき

入力フォームの外側にあるボタン(モーダルのフッターにある「送信」ボタンなど)が、フォーム内部の状態にアクセスする必要がある場合です。onFormStateChangeのようなPropsでバケツリレーをするのではなく、Providerを上位に持ち上げること(State Lifting)で、フォーム内部と外部のボタンが同じContextを共有できます。

レイアウト構造が抜本的に変わるとき

機能のON/OFFだけでなく、「ページ全体で表示する場合」と「モーダル内で表示する場合」のように、配置場所がバラバラになるケースでも威力を発揮します。

たとえば、管理画面の「連絡先編集フォーム」を想定します。ページ表示ではタイトルは左上、保存ボタンは右上のヘッダー内に配置されますが、モーダル表示ではタイトルはモーダルヘッダー、保存ボタンはモーダル下部のフッターに配置されます。

Props設計では「layout="modal"ならボタンを下部に配置」といった分岐が必要になりますが、Compound Componentsなら、親コンポーネントがTitleSubmitButtonを好きなDOM階層(ヘッダー、フッター、サイドバーなど)に自由に配置できます。

データ取得を含むドメイン機能をカプセル化したいとき

UI状態だけでなく、データフェッチや更新処理までをCompound ComponentsのRootに隠蔽するパターンも有効です。

// 利用側はcontactIdを渡すだけ
<EditContact.Root contactId={id}>
  <EditContact.Form />
  <EditContact.SubmitButton />
</EditContact.Root>

EditContact.Rootの内部でuseContact(データ取得)やupdateContact(保存処理)を実行し、子コンポーネントはContextから必要なデータを受け取ります。利用側はデータの読み込み処理を意識する必要がありません。


逆に、ButtonBadgeのように構造が常に一定で、単にデータを流し込むだけのコンポーネントには過剰です。例えば、<Button variant="primary">送信</Button>のようなコンポーネントをわざわざ<Button.Root><Button.Label>送信</Button.Label></Button.Root>と分割する必要はありません。

Compound Componentsを支える3つの原則

Compound Componentsを支える設計原則は次の3つです。

  1. 暗黙的な状態共有: 親がContextで状態を管理し、子はpropsを受け取らずに状態にアクセス
  2. 宣言的な構造: JSXの構造自体が「このUIは何をするものか」を表現
  3. 拡張ポイントの提供: 標準部品では足りない場合にカスタム要素を差し込める「逃げ道」

原則1:暗黙的な状態共有

親コンポーネント(Root)がReactのContext APIで状態を管理し、子コンポーネントはpropsを受け取らずに状態へアクセスします。

// RootがContextで状態を提供
function ComposerRoot({ children, onSubmit }) {
  const [value, setValue] = useState('')
  const [isSubmitting, setIsSubmitting] = useState(false)

  return (
    <ComposerContext.Provider value={{ value, setValue, isSubmitting, onSubmit }}>
      {children}
    </ComposerContext.Provider>
  )
}

// 子コンポーネントはContext経由で状態にアクセス
function ComposerSendButton() {
  const { isSubmitting, value } = useComposerContext()
  return (
    <button type="submit" disabled={isSubmitting || !value}>送信</button>
  )
}

Providerをコンポーネントツリーの上位に配置すれば、入力欄とその外側にあるボタン(モーダルのフッターなど)が同じContextを共有できます。また、Providerのインターフェースを統一しておけば、Provider自体を差し替えることで異なるロジック(ローカルstate / グローバル同期など)に対応できます。

原則2:宣言的な構造

UIの構造がそのまま意図を表現します。どのボタンがあり、どこに配置されているかは、JSXを見れば一目瞭然です。

// 構造 = 意図
<Composer.Root>
  <Composer.Input />           {/* 入力欄がある */}
  <Composer.Actions>           {/* アクション群がある */}
    <Composer.EmojiButton />   {/* 絵文字ボタンがある */}
  </Composer.Actions>
  <Composer.SendButton />      {/* 送信ボタンがある */}
</Composer.Root>

原則3:拡張ポイントの提供

標準の部品で足りない場合、カスタム要素を挿入できる「逃げ道(Escape Hatch)」が必要です。render propパターンを使えば、render propに関数を渡すだけで、任意のコンポーネントを差し込めます。

// カスタム送信ボタンを使いたい場合
<Composer.Root>
  <Composer.Input />
  <Composer.SendButton render={(props) => (
    <MyCustomButton {...props} icon={<SendIcon />} variant="primary">送信する</MyCustomButton>
  )} />
</Composer.Root>

Compound Componentsに「拡張性」を与える設計パターン

なぜ拡張性が必要か

Compound Componentsを提供していると、「送信ボタンを自社デザインシステムのPrimaryButtonにしたい」といった要望が出てくることがあります。標準で提供する部品だけでは、すべてのデザイン要件に対応できません。

この「拡張性」を実現するために、複数のアプローチが存在します。本記事ではBase UI[4]が採用するrender propパターンを中心に解説します。

render propパターンによる拡張

render propパターンとは、コンポーネントにrenderという名前のpropを用意し、そこに「どう描画するか」を関数やReact要素として渡せるようにする設計です。コンポーネント側はロジック(状態管理やアクセシビリティ)を担当し、見た目(DOM構造やスタイリング)の決定権を利用者に委ねます。

Base UIでは「要素を渡すパターン」と「関数を渡すパターン」の両方をサポートしていますが、本記事では型安全性の観点から関数型render propにフォーカスします。

// Base UIのアプローチ例
import { Button } from '@base-ui-components/react/button'

function MyButton({ children, ...props }) {
  return (
    <Button.Root
      {...props}
      className={({ pressed }) => pressed && 'pressed'}
    >
      {children}
    </Button.Root>
  )
}

// カスタム要素でレンダリングしたい場合(関数型render prop)
function MyCustomButton({ children, ...props }) {
  return (
    <Button.Root
      {...props}
      render={(buttonProps, state) => (
        <MyStyledButton
          {...buttonProps}
          data-pressed={state.pressed || undefined}
          variant="primary"
        >
          {children}
        </MyStyledButton>
      )}
    />
  )
}

このパターンの強みは、関数のシグネチャによってTypeScriptがpropsの型を厳密に推論できる点です。どのpropsが渡されるかがIDEの補完で明確になり、型安全性が高まります。

カスタムコンポーネントの作り方

Compound Componentsでrender propパターンを活用するには、Contextから状態を取得するhookを公開しつつ、render propでカスタム要素を差し込めるようにします

// SendButton(render prop対応)
type SendButtonRenderProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  'aria-busy': boolean
}

// childrenかrenderのどちらか一方のみを許可(排他的ユニオン型)
type ComposerSendButtonProps =
  | { children: React.ReactNode; render?: never }
  | { render: (props: SendButtonRenderProps) => React.ReactElement; children?: never }

function ComposerSendButton({ children, render }: ComposerSendButtonProps) {
  const { value, isSubmitting, submit } = useComposerContext()

  const buttonProps: SendButtonRenderProps = {
    disabled: !value.trim() || isSubmitting,
    'aria-busy': isSubmitting,
    onClick: () => submit(),
    children,
  }

  // render propが渡された場合はカスタム要素を使用
  if (render) {
    return render(buttonProps)
  }

  return <button type="button" {...buttonProps} />
}

このrender propを使えば、任意のデザインシステムのボタンを送信ボタンとして使えます。

// 標準のSendButton(childrenを使用)
<Composer.SendButton>送信</Composer.SendButton>

// カスタムボタンを使用(renderを使用)
// propsにはdisabled, onClick, aria-busyなどが含まれる
<Composer.SendButton render={(props) => <IconButton {...props} icon={<PaperPlaneIcon />} />} />

他の合成パターンについて

合成パターンにはrender prop以外にも、asChild(Radix UI)やhooks(React Aria)などの選択肢があります。

特性 asChild (Radix UI) render prop (Base UI)
DOM構造 クリーン(マージされる) 制御可能(開発者次第)
記述量 少ない(宣言的) 多い(関数的)
内部状態へのアクセス 不可(data属性経由のみ) 可(引数として受領)
型安全性 暗黙的(マージ依存) 明示的(引数依存)

本記事ではrender propを例にしましたが、プロジェクトの方針に応じて選択してください。関連記事も参照してください。

https://zenn.dev/tsuboi/articles/8abddb1ae3038f

実践:Compound ComponentsパターンでComposerを実装する

Fernando Rojo氏の講演を参考に、Slackのメッセージ入力欄を模した「Composer」コンポーネントをCompound Componentsパターンで実装します。

実装コードは長いため折りたたんでいます。

Composerの実装コード
import { createContext, useContext, useState, useRef } from 'react'

// Types
type ComposerContextValue = {
  value: string
  setValue: (value: string) => void
  isSubmitting: boolean
  submit: () => Promise<void>
  inputRef: React.RefObject<HTMLTextAreaElement>
}

// Context
const ComposerContext = createContext<ComposerContextValue | null>(null)

function useComposerContext() {
  const context = useContext(ComposerContext)
  if (!context) {
    throw new Error('Composerの各コンポーネントはComposer.Rootの内側で使ってください')
  }
  return context
}

// Root
type ComposerRootProps = {
  children: React.ReactNode
  defaultValue?: string
  onSubmit: (value: string) => void | Promise<void>  // 同期・非同期どちらも許容
}

function ComposerRoot({ children, defaultValue = '', onSubmit }: ComposerRootProps) {
  const [value, setValue] = useState(defaultValue)
  const [isSubmitting, setIsSubmitting] = useState(false)
  const inputRef = useRef<HTMLTextAreaElement>(null)

  const submit = async () => {
    if (!value.trim() || isSubmitting) return

    setIsSubmitting(true)
    try {
      await onSubmit(value)
      setValue('')
      inputRef.current?.focus()
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <ComposerContext.Provider value={{ value, setValue, isSubmitting, submit, inputRef }}>
      {children}
    </ComposerContext.Provider>
  )
}

// Input(render prop対応)
type InputRenderProps = Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'> & {
  inputRef: React.RefObject<HTMLTextAreaElement>  // refを別フィールドとして分離
  value: string
  onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
}

type ComposerInputProps = {
  render?: (props: InputRenderProps) => React.ReactElement
} & Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'>

function ComposerInput({ render, ...props }: ComposerInputProps) {
  const { value, setValue, inputRef } = useComposerContext()

  const baseProps: Omit<InputRenderProps, 'inputRef'> = {
    value,
    onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => setValue(e.target.value),
    ...props,
  }

  // render propが渡された場合はカスタム要素を使用
  if (render) {
    return render({ ...baseProps, inputRef })
  }

  return <textarea ref={inputRef} {...baseProps} />
}

// SendButton(render prop対応)
type SendButtonRenderProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  'aria-busy': boolean
}

// childrenかrenderのどちらか一方のみを許可(排他的ユニオン型)
type ComposerSendButtonProps =
  | { children: React.ReactNode; render?: never }
  | { render: (props: SendButtonRenderProps) => React.ReactElement; children?: never }

function ComposerSendButton({ children, render }: ComposerSendButtonProps) {
  const { value, isSubmitting, submit } = useComposerContext()

  // type="submit"は付与しない(利用者が<form>を使うなら自分で付ける)
  // submit()内でisSubmittingをチェックしているため、二重実行は防止される
  const buttonProps: SendButtonRenderProps = {
    disabled: !value.trim() || isSubmitting,
    'aria-busy': isSubmitting,
    onClick: () => submit(),
    children,
  }

  // render propが渡された場合はカスタム要素を使用
  if (render) {
    return render(buttonProps)
  }

  return <button type="button" {...buttonProps} />
}

// CharCount(render prop対応)
type ComposerCharCountProps = {
  max: number
  render?: (props: { count: number; max: number; isOver: boolean }) => React.ReactNode
}

function ComposerCharCount({ max, render }: ComposerCharCountProps) {
  const { value } = useComposerContext()
  const count = value.length
  const isOver = count > max

  // render propが渡された場合はカスタム要素を使用
  if (render) {
    return <>{render({ count, max, isOver })}</>
  }

  return (
    <span aria-live="polite" data-over={isOver}>
      {count} / {max}
    </span>
  )
}

// Actions Container
function ComposerActions({ children }: { children: React.ReactNode }) {
  return <div role="toolbar" aria-label="メッセージアクション">{children}</div>
}

export const Composer = {
  Root: ComposerRoot,
  Input: ComposerInput,
  SendButton: ComposerSendButton,
  CharCount: ComposerCharCount,
  Actions: ComposerActions,
}

// Context hookを別途export(高度なカスタマイズ用)
export { useComposerContext }

使用例は以下の通りです。

// 基本(<form>は利用者側で配置)
<Composer.Root onSubmit={async (value) => await sendMessage(value)}>
  <form onSubmit={(e) => e.preventDefault()}>
    <Composer.Input placeholder="メッセージを入力..." />
    <Composer.Actions>
      <EmojiPickerButton />
      <AttachmentButton />
    </Composer.Actions>
    <Composer.CharCount max={4000} />
    <Composer.SendButton>送信</Composer.SendButton>
  </form>
</Composer.Root>

// スレッド用(render propでカスタムボタン)
<Composer.Root onSubmit={handleReply}>
  <Composer.Input placeholder="返信を入力..." />
  <Composer.SendButton render={(props) => (
    <IconButton {...props} icon={<ReplyIcon />}>返信</IconButton>
  )} />
</Composer.Root>

// 編集モード(初期値を設定してカスタムUI)
<Composer.Root defaultValue={originalMessage} onSubmit={handleEdit}>
  <Composer.Input />
  <div className="edit-actions">
    <Button variant="ghost" onClick={onCancel}>キャンセル</Button>
    <Composer.SendButton render={(props) => (
      <Button {...props} variant="primary">保存</Button>
    )} />
  </div>
</Composer.Root>

// CharCountのrender propでカスタム表示
<Composer.Root onSubmit={handleSubmit}>
  <Composer.Input placeholder="メッセージを入力..." />
  <Composer.CharCount
    max={4000}
    render={({ count, max, isOver }) => (
      <span className={isOver ? 'text-red-500' : 'text-gray-500'}>
        残り {max - count} 文字
      </span>
    )}
  />
  <Composer.SendButton>送信</Composer.SendButton>
</Composer.Root>

プロダクションで使うための補足

上記のコードは「最小限で動くサンプル」です。実際のプロダクトで使う場合は、以下のポイントを押さえます。

refのマージ

render propでカスタムコンポーネントを差し込む場合、外部から渡されたrefと内部で使用しているrefの両方を同じDOM要素に適用します。これを怠ると、親コンポーネントがref経由でDOM要素にアクセスできず、フォーカス制御などが動作しなくなります。

// 複数のrefをマージするユーティリティ
function useMergeRefs<T>(...refs: (React.Ref<T> | undefined)[]) {
  return (node: T | null) => {
    refs.forEach((ref) => {
      if (typeof ref === 'function') {
        ref(node)
      } else if (ref != null) {
        (ref as React.MutableRefObject<T | null>).current = node
      }
    })
  }
}

// 使用例(外部からrefを受け取る場合)
type InputProps = {
  ref?: React.Ref<HTMLTextAreaElement>
} & Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'>

function ComposerInput({ ref: externalRef, ...props }: InputProps) {
  const { value, setValue, inputRef } = useComposerContext()
  const mergedRef = useMergeRefs(inputRef, externalRef)

  return (
    <textarea
      ref={mergedRef}
      value={value}
      onChange={(e) => setValue(e.target.value)}
      {...props}
    />
  )
}
補足

React 19ではrefコールバックがクリーンアップ関数を返せるようになりました。上記のuseMergeRefsは簡易版のため、クリーンアップ関数の処理は省略しています。プロダクションではRadix UIの実装などを参考にしてください。

State Liftingの実装例

記事前半で説明した「Providerを上位に配置して状態を共有する」テクニックの具体例です。入力欄(Modal.Body内)と送信ボタン(Modal.Footer内)が離れた位置にある転送画面を実装できます。

// Providerだけを公開(Rootはformを含むため分離)
type ComposerProviderProps = {
  children: React.ReactNode
  onSubmit: (value: string) => void | Promise<void>
}

function ComposerProvider({ children, onSubmit }: ComposerProviderProps) {
  const [value, setValue] = useState('')
  const [isSubmitting, setIsSubmitting] = useState(false)
  const inputRef = useRef<HTMLTextAreaElement>(null)

  const submit = async () => {
    if (!value.trim() || isSubmitting) return
    setIsSubmitting(true)
    try {
      await onSubmit(value)
      setValue('')
      inputRef.current?.focus()
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <ComposerContext.Provider value={{ value, setValue, isSubmitting, submit, inputRef }}>
      {children}
    </ComposerContext.Provider>
  )
}

// 使用例:入力欄と送信ボタンが離れた位置にあるケース
function ForwardMessageModal({ onSubmit, onClose }) {
  return (
    <ComposerProvider onSubmit={onSubmit}>
      <Modal>
        <Modal.Header>メッセージを転送</Modal.Header>
        <Modal.Body>
          <Composer.Input placeholder="コメントを追加..." autoFocus />
        </Modal.Body>
        <Modal.Footer>
          <Button variant="ghost" onClick={onClose}>キャンセル</Button>
          {/* render propでカスタムボタンを使用 */}
          <Composer.SendButton render={(props) => (
            <Button {...props} variant="primary">転送</Button>
          )} />
        </Modal.Footer>
      </Modal>
    </ComposerProvider>
  )
}

ComposerProviderModalの外側に配置することで、Composer.InputComposer.SendButtonが同じContextを共有できます。Props drillingなしで、離れた場所にあるコンポーネント間で状態を連携できます。

AIとCompound Components

Fernando Rojo氏は講演およびインタビューの中で、「この講演のスライドをAIコード生成ツール(v0)に読み込ませて、状態管理やレンダリングにCompositionを使うよう指示したところ、バグの少ないコードを書くことに驚くほど成功した」と語っています[1:3][5]

AIはGitHub上の「条件分岐だらけのコード」も大量に学習しているため、放っておくとメンテナンスしにくいコードを生成しがちです。しかし、Compound Componentsという「明確な型と構造の制約」を与えることで、AIはコンテキストを正しく理解し、人間にとっても読みやすく、バグの少ないコードを出力できるようになります。

また、Compositionパターンはエスケープハッチとしても機能します。AIが生成したコードに問題があっても、プロンプトを修正せずに手動で調整しやすいという実用的な利点があります。大きなMonolith(一枚岩)コンポーネントにAIがコードを追加すると意図せぬ分岐や依存を生みやすいですが、Compound Componentsで「変更可能な範囲」を小さく区切っておけば、影響範囲を限定できます。「Composer.SendButtonだけを修正して」のように、プロンプトで触らせる部品を明示的に指定できるのも利点です。

つまり、この設計パターンは人間だけでなく、AIとのペアプログラミングを円滑にする共通言語にもなります。

まとめ

showFooter={false}ではなく、<Composer.Footer>を書かない——これがCompound Componentsの発想です。

Boolean Propsで機能をON/OFFするのではなく、JSXの構造そのものでUIを宣言する。このアプローチにより、条件分岐の爆発を避け、コードの意図を明確に保てます。状態管理はContextに任せ、拡張が必要な場面では合成パターン(render prop、hooks、asChildなど)で逃げ道を用意する。本記事ではrender propを採用しましたが、プロジェクトの要件に応じて最適なパターンを選択してください。構造が明確になることで、人間だけでなくAIにとっても理解しやすいコードになります。

すべてのコンポーネントにCompound Componentsが必要なわけではありません。しかし、「またBooleanが増えた」と感じたら、それは設計を見直すタイミングかもしれません。

以上です!

脚注
  1. Fernando Rojo - "Composition Is All You Need" - React Universe Conf 2025 - https://www.youtube.com/watch?v=4KvbVq3Eg5w ↩︎ ↩︎ ↩︎ ↩︎

  2. TkDodo - "Designing Design Systems" - https://tkdodo.eu/blog/designing-design-systems ↩︎

  3. Kent C. Dodds - "Advanced React Patterns" - https://kentcdodds.com/blog/compound-components-with-react-hooks ↩︎

  4. Base UI - Composition - https://base-ui.com/react/handbook/composition ↩︎

  5. Fernando Rojo - Interview at React Universe Conf 2025 - https://youtu.be/jnYn9ww-YQs ↩︎

Discussion

dog_cat_foxdog_cat_fox
<Composer
    showFooter={showFooter}
/>

みたいに showFooter の値によって変わってくる場合はどうなるんだろう。
こんな感じかな。

<Composer.Root>
  { showFooter && <Composer.Footer> }
</Composer.Root>
TsuboiTsuboi

コメントありがとうございます!
利用側で {showFooter && ...}と書くのは、Inversion of Control(制御の反転)の原則に従う、何を表示するかは利用者がJSXで宣言するの実践なのでその理解で合ってると思います