「Composition Is All You Need」は、VercelのFernando Rojo氏がReact Universe Conf 2025で行った約22分の講演です。Slackのメッセージ入力欄(コンポーザー)を題材に、用途が増えるたびに isThread や isEditing のようなboolean propsを足していく設計がなぜ破綻するのか、それをCompound Componentsと状態の持ち上げでどう組み直すのかを示しました。
この記事では講演の流れを字幕に沿って整理し、React 19.3で実際に動かしたコードで組み直しの手順を再現します。講演の内容は2026年1月にVercelのAIエージェント向けスキル「composition-patterns」としても公開されており、その8つのルールとReact Compiler 1.0での注意点もあわせて扱います。
まとめ:講演の要点と採用の判断
- 問題は「親から渡すbooleanで描画する部品の木を切り替える」設計です。用途が1つ増えるごとに条件分岐が部品の奥まで散らばります。
- 解決策は、部品を
Composer.Frame・Composer.Inputのような小さな部品に割り、用途ごとに別のコンポーネント(ThreadComposer、EditMessageComposer)としてJSXで組み直すことです。 - 状態は
state・actions・metaの3つを持つContextで受け渡し、実装(useStateか端末間同期のフックか)はProviderだけが知る形にします。 - 講演者が「1つだけ持ち帰るなら」(17:34)と挙げたのは状態の持ち上げです。Providerを外へ出せば、入力欄の枠の外にあるボタンからも送信できます。
- 用途が2つ程度で今後も増えない部品や、
disabledのようにHTMLの属性そのものを表すbooleanにまで適用する必要はありません。
Reactのコンポジションとは:childrenとpropsで部品を組む基本
Reactでいうコンポジション(composition)は、部品を継承で拡張するのではなく、children やpropsでJSXを受け取り、小さな部品を組み合わせてUIを作る考え方です。旧公式ドキュメントの「Composition vs Inheritance」は「At Facebook, we use React in thousands of components, and we haven’t found any use cases where we would recommend creating component inheritance hierarchies.」と書き、継承の階層を勧める場面は見つかっていないと述べています。
この基本形自体は古くからあります。講演が新しかったのは、基本形を知っている開発者でも実務では「既存の部品にフラグを1つ足す」方向へ流れやすいことを、具体的な画面で順に見せた点です。children とpropsの使い分けはReactのchildrenとpropsの違い、オブジェクト指向側の議論は継承とは?オブジェクト指向での使いどころ・委譲との使い分けで扱っています。
講演の概要:発表者・会場・公開先
| 項目 | 内容 |
|---|---|
| 講演名 | Composition Is All You Need |
| 発表者 | Fernando Rojo(Vercel) |
| 会場 | React Universe Conf 2025(主催 Callstack) |
| 動画 | CallstackのYouTubeチャンネル・約22分 |
| スキル化 | vercel-labs/agent-skills の composition-patterns |
Rojo氏は講演の終盤で、v0のモバイルアプリを開発していること、講演のスライドをv0に読ませて「コンポジションで書くよう」指示したらバグが減ったことを話しています。講演の締めは「15個のbooleanに埋もれたら、composition is all you needを思い出して」という一言です。
boolean propsが増える経緯:Slackのコンポーザーの例
講演の導入はユーザー登録フォームです。更新用にも使うため isUpdateUser を足し、更新時は歓迎文と利用規約を隠すためのbooleanを足し、更新成功時はオンボーディングへ飛ばさない分岐を足す。さらに名前だけを編集する画面のために onlyEditName を足す――という流れで、汎用フォームの中身が読めなくなる過程を見せます。
本題のSlackのコンポーザー(動画の1:58〜)は、見た目は1つでも次の用途で少しずつ違います。
| 用途 | 他と違う点 | 状態の持ち方 |
|---|---|---|
| チャンネル | 基準形(添付・絵文字・送信) | 端末間で同期 |
| チャンネルのスレッド | 「チャンネルにも送信」欄 | 端末間で同期 |
| DMのスレッド | 「DMとしても送信」欄 | 端末間で同期 |
| メッセージ編集 | 添付なし・キャンセルと保存 | 端末間で同期 |
| メッセージ転送 | 送信ボタンが入力欄の外 | ダイアログ内だけ |
素直に作ると、用途ごとに isThread・channelId・isDMThread・dmId・isEditingMessage・isForwardingMessage が足され、部品の中は次のような三項演算子の連なりになります。
function Composer({ onSubmit, isThread, channelId, isDMThread, dmId, isEditing, isForwarding }) {
return (
<form>
<Header />
<Input />
{isDMThread ? <AlsoSendToDMField id={dmId} /> : isThread ? <AlsoSendToChannelField id={channelId} /> : null}
{isEditing ? <EditActions /> : isForwarding ? <ForwardActions /> : <DefaultActions />}
<Footer onSubmit={onSubmit} hideAttachments={isEditing} />
</form>
);
}
編集では添付の「+」ボタンが無いので、ドラッグ&ドロップの受け口も止めなければなりません。フッターのボタンを配列で持っていると isMenu や divider のような項目別の設定が配列に増え、それを回す側の実装が崩れます。Rojo氏は「同じ条件をあちこちで使っているなら、コンポジションの出番かもしれない」「親から渡すbooleanで描画する部品の木を決めているなら、肩越しに首を振っている私を想像してほしい」(8:16)と述べています。
boolean props 1つで組み合わせは2倍になります。Vercelのスキルのルール文も「Each boolean doubles possible states」と書いており、上の7引数のうちbooleanの4つだけでも16通りの組み合わせを部品の中で区別することになります。
Compound Componentsでの組み直し:用途別の部品構成
講演は「Radixのコンポーネントのように機能を作ったらどうか」(10:04)と問いかけ、1つの大きな部品を Composer.Provider・Composer.Frame・Composer.Header・Composer.Input・Composer.Footer に割ります。部品同士はContextで状態を共有し、使う側は必要な部品だけをJSXで並べます。この形をCompound Componentsと呼び、Radix UIのPrimitivesも同じ構造です。
部品の定義:Contextを読む小さな部品の集まり
次のコードはReact 19.3.0で動かしたものです。useComposer はProviderの外で呼ばれたら例外を投げ、使い方の誤りを早く見つけられるようにしています。
import { createContext, use } from 'react';
const ComposerContext = createContext(null);
export function useComposer() {
const ctx = use(ComposerContext);
if (ctx === null) throw new Error('Composer.* は Provider の内側で使う');
return ctx;
}
function ComposerProvider({ state, actions, meta, children }) {
return <ComposerContext value={{ state, actions, meta }}>{children}</ComposerContext>;
}
function ComposerFrame({ children }) {
return <form onSubmit={(e) => e.preventDefault()}>{children}</form>;
}
function ComposerInput({ placeholder }) {
const { state, actions, meta: { inputRef } } = useComposer();
return (
<textarea
ref={inputRef}
aria-label="メッセージ"
placeholder={placeholder}
value={state.input}
onChange={(e) => actions.update((s) => ({ ...s, input: e.target.value }))}
/>
);
}
function ComposerSubmit() {
const { actions } = useComposer();
return <button type="button" onClick={actions.submit}>送信</button>;
}
export const Composer = {
Provider: ComposerProvider,
Frame: ComposerFrame,
Input: ComposerInput,
Submit: ComposerSubmit,
// Header・Footer・Emojis・Formatting・Attachments も同じ要領で足す
};
用途ごとのコンポーザー:booleanの代わりに別コンポーネント
スレッド用は、チャンネル用に「チャンネルにも送信」欄を1つ足した別コンポーネントになります。以下は構成を示す抜粋で、Header・Footerなどの部品、EditMessageProvider、保存・キャンセル処理、同期用フックの実装は省略しています。そのまま実行できる例は、後述の転送ダイアログと、それが使う部品・Providerの組み合わせです。編集用は添付とドロップ領域を最初から置かず、フッターには書式と絵文字、キャンセルと保存のボタンを直接並べます。講演は「booleanで決めるのではなく、描画しないだけ」と説明しています。
function ThreadComposer({ channelId }) {
return (
<ChannelProvider channelId={channelId}>
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<AlsoSendToChannelField channelId={channelId} />
<Composer.Footer>
<Composer.CommonActions />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
</ChannelProvider>
);
}
function EditMessageComposer({ messageId, onCancel }) {
return (
<EditMessageProvider messageId={messageId}>
<Composer.Frame>
<Composer.Input />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<CancelButton onClick={onCancel} />
<SaveButton />
</Composer.Footer>
</Composer.Frame>
</EditMessageProvider>
);
}
多くの用途で共通する左下のボタン群は CommonActions という部品に束ねます。ここで講演が強調するのは、CommonActions が isEditingMessage のようなpropsを受け取らないことです。編集用のように形が違う用途は CommonActions を使わず、個別の部品を並べ直します。共通化はしてよいが、共通化した部品に分岐を戻さない、という線引きです。
状態の持ち上げ:state・actions・metaのインターフェース
コンポーザーの状態は用途で持ち方が違います。チャンネルやスレッドの下書きは入力中に端末間で同期されますが、転送ダイアログの下書きは閉じたら破棄されます。講演はContextが「インターフェース(state・actions・meta)」だけを決め、実装はProviderを描画する側が持つ形を示しました。
Providerが実装を隠す:useStateと同期フックの差し替え
転送用のProviderは useState で持ち、actions.update に setState をそのまま渡します。チャンネル用は講演中の架空のフック useGlobalChannel が返す値を同じ形に当てはめるだけです。Composer.Input はどちらのProviderの下でも同じコードで動きます。meta には入力欄のrefのように「状態ではないが部品間で共有したいもの」を入れ、どの部品からでも入力欄にフォーカスできるようにします。
import { useState, useRef } from 'react';
export function ForwardMessageProvider({ onForward, children }) {
const [state, setState] = useState({ input: '' });
const inputRef = useRef(null);
const submit = () => onForward(state.input);
return (
<Composer.Provider state={state} actions={{ update: setState, submit }} meta={{ inputRef }}>
{children}
</Composer.Provider>
);
}
function ChannelProvider({ channelId, children }) {
const { state, update, submit } = useGlobalChannel(channelId); // 端末間で同期する実装
const inputRef = useRef(null);
return (
<Composer.Provider state={state} actions={{ update, submit }} meta={{ inputRef }}>
{children}
</Composer.Provider>
);
}
Frameの外にあるボタン:Provider内での状態と操作の共有
Slackの転送ダイアログでは「転送」ボタンが入力欄の枠の外、ダイアログの最下段にあります。isForwardingMessage を足して、入力値を useEffect で親へ返す必要はありません。Providerを外側の部品へ持ち上げれば、枠の外の部品も同じContextを読めます。
function ForwardButton() {
const { state, actions } = useComposer();
return <button type="button" onClick={actions.submit}>転送({state.input.length}字)</button>;
}
export function ForwardMessageDialog({ onForward }) {
return (
<ForwardMessageProvider onForward={onForward}>
<Composer.Frame>
<Composer.Input placeholder="メッセージを追加(任意)" />
</Composer.Frame>
{/* Frame の外だが Provider の内側にある */}
<ForwardButton />
</ForwardMessageProvider>
);
}
react-dom 19.3.0とjsdomでこのダイアログを描画し、入力欄に「お疲れさまです」と入れて転送ボタンを押したところ、ボタンの表示は「転送(7字)」になり、onForward には「お疲れさまです」が渡りました。ボタンの祖先に form 要素は無く、枠の外から送信できています。Provider無しで Composer.Input だけを描画すると、useComposer の例外が出ます。
React 19.3とReact Compiler 1.0で変わる書き方
講演のコードはReact 19の書き方を前提にしています。npmの react の最新版は19.3.0(2026年9月9日公開)、React Compilerの babel-plugin-react-compiler は1.0.0(2025年10月7日公開)が最新です。版ごとの変更点はReact 19とは?18からの変更点・新機能にまとめています。
| 書き方 | React 18まで | React 19以降 |
|---|---|---|
| Providerの指定 | <Ctx.Provider value> | <Ctx value> |
| Contextの読み取り | useContext(Ctx) | use(Ctx) も可 |
| refの受け取り | forwardRef で包む | ref をpropsで受ける |
React 19の公式ブログは、関数コンポーネントでは forwardRef が不要になり、将来の版で非推奨化して削除すると書いています。use は条件分岐やループの中でも呼べる点が useContext と違いますが、公式リファレンスは「Contextを use で読むのはServer Componentsでは非対応」としています。useContext 自体は非推奨ではありません。Vercelのスキルが「useContext の代わりに use」と書いているのは同社の方針です。Contextの基本はReact Contextの使い方を参照してください。
Contextの再レンダリングとReact Compiler
講演は「Contextによる再レンダリングが心配ならReact Compilerを使えばよい」と述べ、Playgroundで Composer.Input の返すJSXが、Contextから取り出して使用する値に応じてメモ化されることを見せています。これはContext更新時に購読コンポーネントの関数呼び出し自体を必ず省略するという意味ではありません。一方で、Compilerは対象にできない書き方を見つけると、エラーにせずその関数を素のまま出力します。babel-plugin-react-compiler 1.0.0で次の2点を確認しました。
ref={meta.inputRef}のようにオブジェクトのプロパティをそのままrefに渡すと、@babel/core 7.29.7で「Cannot access refs during render」となり、その部品はメモ化されません。const { meta: { inputRef } } = useComposer()と分割代入してから渡すと成功します。Vercelのスキルは、Compound Componentsのルールでは分割代入、Contextのインターフェースのルールではmeta.inputRefと、2通りの書き方が混在しています。- @babel/core 8.0.6と組み合わせると、
{ placeholder = '...' }のようにpropsの既定値を分割代入で書いた部品が「BuildHIR::lowerAssignment」でコンパイル対象外になりました。同じコードは7.29.7では成功します。
どちらもビルドは通るので気づきにくい差です。logger オプションの logEvent で CompileError を出力すると、どの部品が外れたかを確認できます。Compilerの仕組みはReact Compilerとは?自動メモ化の仕組みと導入方法で解説しています。
Vercelのcomposition-patternsスキル:AIエージェント向けの8ルール
Rojo氏は講演で、コンポジションで書かれたコードは「AIがハルシネーションを起こしにくい」と述べました。その内容は2026年1月26日にRojo氏のコミットで vercel-labs/agent-skills リポジトリの skills/composition-patterns として公開されています(metadata.jsonの版は1.0.0)。npx skills add vercel-labs/agent-skills で導入すると、エージェントがReactの部品を書く・見直す場面でこのルールを参照します。
| 分類(優先度) | ルール名 | 内容 |
|---|---|---|
| Architecture(HIGH) | avoid-boolean-props | 挙動の切り替えにbooleanを足さない |
| Architecture(HIGH) | compound-components | 共有Contextを持つ部品群に割る |
| State(MEDIUM) | decouple-implementation | 状態の実装はProviderだけが知る |
| State(MEDIUM) | context-interface | state・actions・metaの汎用型 |
| State(MEDIUM) | lift-state | 兄弟から読む状態はProviderへ |
| Patterns(MEDIUM) | explicit-variants | モードではなく別コンポーネント |
| Patterns(MEDIUM) | children-over-render-props | renderXより children |
| React 19(MEDIUM) | no-forwardref | refはpropsで受け取り、Contextはuse()で読む |
スキルのコード例はReact Nativeの TextInput と onPress で書かれています。Web向けのReactで使う場合は textarea と onClick に読み替えが必要で、エージェントがそのまま写すとWebでは動きません。React 19専用のルールは「React 18以前ならこの節は飛ばす」と明記されているため、React 18のプロジェクトでは除外を指示しておきます。
コンポジションを採用しない方がよい場面
講演の処方は「同じ条件分岐が部品のあちこちに出てくる」状態に向けたものです。次の場合は、booleanを1つ足す方が読みやすく、部品を割る作業はコストだけが残ります。
- 用途が2つで、3つ目の予定が無い部品:分岐が1か所に収まるなら、
Composer.*の部品群とProviderを作る方がコード量は増えます。 - HTMLの属性そのものを表すboolean:
disabled・required・checkedは描画する部品の木を切り替えないため、講演の批判の対象外です。 - 見た目だけの差:色やサイズの違いは
variant="primary"のような文字列propsで足ります。描画する子が変わるときにだけ別コンポーネントを検討します。 - Server Componentsが中心の画面:Contextを読む部品や入力状態を更新する部品はすべてClient Componentになります。Server ComponentからそれらやClient側で定義したProviderを描画することはできますが、静的な表示が中心の画面ではCompound Componentsにする利点は小さくなります。
判断の目安は、booleanが「部品の中の何を描画するか」を決めているかどうかです。決めているなら、同じ条件が複数の部品に現れた時点で組み直しを検討します。見るのはbooleanの個数より、分岐が何か所に重複しているかと、今後足す用途の数です。カスタムフックへの切り出し方はReactカスタムフックのベストプラクティスで扱っています。
よくある質問
「Composition Is All You Need」の動画はどこで見られますか?
React Universe Conf 2025を主催したCallstackのYouTubeチャンネルで公式動画が公開されています。タイトルは「Composition Is All You Need | Fernando Rojo at React Universe Conf 2025」で、長さは約22分です。英語の字幕があります。
shouldRenderButtonやisEditingは講演に出てくる名前ですか?
講演の英語字幕を全文検索すると、出てくるのは is thread・is DM thread・is editing message・is forwarding message などで、shouldRenderButtonという語はありません。Vercelのスキルのコード例でも、使われているのは isThread・isDMThread・isEditing・isForwarding です。
React 18でもこのパターンは使えますか?
使えます。Compound Componentsと状態の持ち上げはReact 16.3以降のContext APIで書けます。React 18では <Ctx.Provider value> と useContext、refを受ける部品は forwardRef で書き換えてください。
render propsとの違いは何ですか?
render propsは renderFooter のような関数propsで描画を差し込みます。講演で指摘しているのは、既存コンポーザーの内部で呼ぶrenderFooterだけでは、その外側にある転送ボタンを配置できないという制約です。render props一般で実現できないという意味ではなく、状態を持つ親から描画関数へ値や操作を渡す設計も可能です。Vercelのスキルも renderX より children を使うルールを置いています。
Contextを使うと再レンダリングが増えませんか?
Contextの値が変わると、そのContextを読む部品はすべて再レンダリングされます。講演はReact Compilerによる自動メモ化で対処する立場です。Compilerを入れない場合は、state と actions を別のContextに分ける、Providerの値や関数の参照を安定させるなど、計測で確認した不要な再レンダリングに応じて対策を検討します。useMemo は依存値が変わった際のContext更新を止めるものではなく、Compilerを使わない場合に必須の処置でもありません。