react-composition-2026 について
柔軟な React コンポーネント API の設計、リファクタリング、レビューに役立つコンポジションパターンのガイドです。複合コンポーネントや明示的なバリアント、状態の分離に加え、React 19 の ref の使い方も扱います。
主な機能
- 01ブール値 prop の増加をコンポジションで回避
- 02Context による複合コンポーネント
- 03明示的なバリアントコンポーネント
- 04children によるコンポジション
- 05状態の実装と UI の分離
- 06ポリモーフィズム、スロット、ヘッドレスコンポーネント
活用シーン
- 01拡張しにくいコンポーネントのリファクタリング
- 02再利用可能なコンポーネントライブラリの API 設計
- 03ブール値 prop が多いコンポーネントのレビュー
- 04共有コンポーネントの状態実装を置き換え可能にする
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ patternsdev/ skills --skill react-composition-2026インストール後、Claude Code で/react-composition-2026を入力して読み込まれたか確認します。
詳細
モダンな React のコンポジションパターン
目次
柔軟で保守しやすく、規模の拡大にも対応できる React コンポーネントを構築するためのコンポジションパターンです。これらのパターンは、増え続ける真偽値の props、柔軟性に欠けるコンポーネント API、複雑に絡み合った状態を、合成可能で明示的な設計に置き換えます。
使用する場面
次のような場合に、これらのパターンを参考にしてください。
- コンポーネントの動作を制御する真偽値の props が 3〜4 個を超える場合
- 再利用可能な UI コンポーネントや共有コンポーネントライブラリを構築する場合
- 拡張が難しいコンポーネントをリファクタリングする場合
- 他のチームが利用するコンポーネント API を設計する場合
- 柔軟性と保守性の観点からコンポーネントアーキテクチャをレビューする場合
手順
- コンポーネントの設計、コード生成、レビュー時にこれらのパターンを適用します。真偽値の props が増え続けていたり、コンポーネント API の柔軟性が不足していたりする場合は、適切なコンポジションパターンを提案してください。
詳細
概要
中心となる原則は、設定よりもコンポジションです。あらゆるバリエーションに対応するために真偽値の props や条件分岐を追加するのではなく、小さく役割を絞ったコンポーネントを組み合わせます。これにより、人間にも AI エージェントにも、コンポーネントが理解しやすく、テストしやすく、拡張しやすくなります。
1. 真偽値の props をコンポジションに置き換える
効果:高 — 組み合わせの爆発を防ぎ、意図を明確にします。
真偽値の props は複雑さを増大させます。真偽値が 4 つあると、可能な状態は 16 通りになり、そのほとんどがテストされていません。これらを、組み合わせ可能な子要素に置き換えます。
避けるべき例 — 真偽値の props が増え続ける:
<Card
showHeader
showFooter
collapsible
bordered
withShadow
headerAction="close"
size="large"
/>
推奨例 — 明示的なコンポジション:
<Card variant="bordered" shadow="md">
<Card.Header>
<h3>Title</h3>
<Card.CloseButton />
</Card.Header>
<Card.Body collapsible>
<p>Content here</p>
</Card.Body>
<Card.Footer>
<Button>Save</Button>
</Card.Footer>
</Card>
それぞれの要素が明示的で、テスト可能かつ個別に省略できます。
2. Context を使って複合コンポーネントを構築する
効果:高 — props のバケツリレーをせずに、暗黙的に状態を共有できます。
複合コンポーネントとは、props ではなく context を通じて状態を共有しながら連携するコンポーネントのグループです。親が状態を管理し、子がそれを利用します。
避けるべき例 — 親が props を通じてすべてを管理する:
<Select
options={options}
value={value}
onChange={onChange}
renderOption={(opt) => <span>{opt.icon} {opt.label}</span>}
renderSelected={(opt) => <b>{opt.label}</b>}
placeholder="Choose..."
clearable
searchable
maxHeight={300}
/>
推奨例 — 複合コンポーネント:
const SelectContext = createContext<SelectState | null>(null)
function Select({ children, value, onChange }: SelectProps) {
const [open, setOpen] = useState(false)
const ctx = useMemo(() => ({ value, onChange, open, setOpen }), [value, onChange, open])
return (
<SelectContext.Provider value={ctx}>
<div className="select-root">{children}</div>
</SelectContext.Provider>
)
}
function Trigger({ children }: { children: React.ReactNode }) {
const { open, setOpen } = useSelectContext()
return <button onClick={() => setOpen(!open)}>{children}</button>
}
function Options({ children }: { children: React.ReactNode }) {
const { open } = useSelectContext()
if (!open) return null
return <ul role="listbox">{children}</ul>
}
function Option({ value, children }: OptionProps) {
const { value: selected, onChange, setOpen } = useSelectContext()
return (
<li
role="option"
aria-selected={value === selected}
onClick={() => { onChange(value); setOpen(false) }}
>
{children}
</li>
)
}
Select.Trigger = Trigger
Select.Options = Options
Select.Option = Option
使用例:
<Select value={color} onChange={setColor}>
<Select.Trigger>Pick a color</Select.Trigger>
<Select.Options>
<Select.Option value="red">Red</Select.Option>
<Select.Option value="blue">Blue</Select.Option>
</Select.Options>
</Select>
3. 明示的なバリエーションコンポーネントを作成する
効果:中 — 各モードを明確で役割の絞られたコンポーネントにできます。
コンポーネントに明確に異なる「モード」(ダイアログとドロワー、インラインとモーダル、カードとリスト項目)がある場合は、props で切り替えるのではなく、明示的なバリエーションコンポーネントを作成します。
避けるべき例 — モード props を持つ単一のコンポーネント:
function MediaDisplay({ type, src, title, showControls, autoPlay, loop }: Props) {
if (type === 'video') {
return <video src={src} controls={showControls} autoPlay={autoPlay} loop={loop} />
}
if (type === 'audio') {
return <audio src={src} controls={showControls} />
}
return <img src={src} alt={title} />
}
推奨例 — 明示的なバリエーション:
function VideoPlayer({ src, controls, autoPlay, loop }: VideoProps) {
return <video src={src} controls={controls} autoPlay={autoPlay} loop={loop} />
}
function AudioPlayer({ src, controls }: AudioProps) {
return <audio src={src} controls={controls} />
}
function Image({ src, alt }: ImageProps) {
return <img src={src} alt={alt} />
}
各バリエーションは必要な props だけを持ちます。不可能な状態も、使われない props もありません。
4. コンポジションには Render Props より children を使う
効果:中 — API がシンプルになり、読みやすさが向上します。
フックが登場する前は Render Props(renderHeader、renderItem)が不可欠でしたが、現在ではほとんどの場合、children を使うほうがすっきりとコンポジションできます。
避けるべき例 — Render Props が増え続ける:
<DataTable
data={users}
renderHeader={() => <h2>Users</h2>}
renderRow={(user) => <UserRow user={user} />}
renderEmpty={() => <EmptyState />}
renderFooter={() => <Pagination />}
/>
推奨例 — children によるコンポジション:
<DataTable data={users}>
<DataTable.Header>
<h2>Users</h2>
</DataTable.Header>
<DataTable.Body>
{users.map(user => <UserRow key={user.id} user={user} />)}
</DataTable.Body>
<DataTable.Empty>
<EmptyState />
</DataTable.Empty>
<DataTable.Footer>
<Pagination />
</DataTable.Footer>
</DataTable>
親からレンダラーにデータを渡す必要がある場合(仮想化リストの項目など)は、Render Props を使用します。
5. 状態の実装と UI を切り離す
効果:中 — コンポーネントを変更せずに状態管理を切り替えられます。
状態の形状(値、アクション、メタデータ)に対する汎用インターフェースを定義し、プロバイダーにそれを実装させます。コンポーネントは実装ではなく、インターフェースを利用します。
インターフェースを定義する:
interface CounterState {
count: number
increment: () => void
decrement: () => void
isLoading: boolean
}
const CounterContext = createContext<CounterState | null>(null)
function useCounter() {
const ctx = useContext(CounterContext)
if (!ctx) throw new Error('useCounter must be used within a CounterProvider')
return ctx
}
ローカル状態で実装する:
function LocalCounterProvider({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0)
const value = useMemo(() => ({
count,
increment: () => setCount(c => c + 1),
decrement: () => setCount(c => c - 1),
isLoading: false,
}), [count])
return <CounterContext.Provider value={value}>{children}</CounterContext.Provider>
}
利用側を変更せずに API ベースの状態管理に切り替える:
function ApiCounterProvider({ children }: { children: React.ReactNode }) {
const { data, mutate } = useSWR('/api/counter', fetcher)
const value = useMemo(() => ({
count: data?.count ?? 0,
increment: () => mutate(patch('/api/counter', { delta: 1 })),
decrement: () => mutate(patch('/api/counter', { delta: -1 })),
isLoading: !data,
}), [data, mutate])
return <CounterContext.Provider value={value}>{children}</CounterContext.Provider>
}
useCounter() の利用側を変更する必要はありません。
6. 状態をプロバイダーコンポーネントに持ち上げる
効果:中 — props を介した受け渡しなしで、兄弟コンポーネント間の連携が可能になります。
兄弟コンポーネントが状態を共有する必要がある場合は、親を経由してコールバックを受け渡すのではなく、状態をプロバイダーに持ち上げます。
避けるべき例 — 親が兄弟コンポーネントに状態を受け渡す:
function Page() {
const [selected, setSelected] = useState<string | null>(null)
return (
<div>
<Sidebar selected={selected} onSelect={setSelected} />
<Detail selected={selected} />
</div>
)
}
推奨例 — プロバイダーが共有状態を管理する:
function SelectionProvider({ children }: { children: React.ReactNode }) {
const [selected, setSelected] = useState<string | null>(null)
return (
<SelectionContext.Provider value={{ selected, setSelected }}>
{children}
</SelectionContext.Provider>
)
}
function Page() {
return (
<SelectionProvider>
<Sidebar />
<Detail />
</SelectionProvider>
)
}
Sidebar と Detail は、どちらも useSelection() を直接利用します。
7. 柔軟な要素にはポリモーフィックな as Props を使用する
影響:中 — 1 つのコンポーネントで、任意の基底要素やコンポーネントに対応できます。
as prop パターンを使うと、コンポーネントのスタイルと動作を維持したまま、レンダリングする要素を利用者が制御できます。
type BoxProps<C extends React.ElementType = 'div'> = {
as?: C
children: React.ReactNode
} & Omit<React.ComponentPropsWithoutRef<C>, 'as' | 'children'>
function Box<C extends React.ElementType = 'div'>({
as,
children,
...props
}: BoxProps<C>) {
const Component = as || 'div'
return <Component {...props}>{children}</Component>
}
使用方法:
<Box>Default div</Box>
<Box as="section">A section</Box>
<Box as="a" href="/about">A link</Box>
<Box as={Link} to="/about">Router link</Box>
8. React 19:forwardRef をやめ、ref を Prop として使用する
影響:中 — コンポーネントの定義がよりシンプルになります。
React 19 では、ref が通常の prop として渡されます。forwardRef のラッパーは不要です。
React 18(非推奨のパターン):
const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
return <input ref={ref} {...props} />
})
React 19:
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />
}
同様に、use() は Promise またはコンテキストを読み取ることができ、条件付きで呼び出せます。
import { use } from 'react'
function Panel({ themePromise }: { themePromise: Promise<Theme> }) {
const theme = use(themePromise) // unwraps promise
const user = use(UserContext) // conditional context read
return <div className={theme.bg}>{user.name}</div>
}
9. レイアウトコンポーネント向けのスロットパターン
影響:中 — Render Props を使わずに、名前付きの挿入ポイントを作成できます。
複数のコンテンツ領域を持つレイアウトコンポーネントには、子の型検出または名前付きサブコンポーネントに基づくスロットパターンを使用します。
function AppLayout({ children }: { children: React.ReactNode }) {
const slots = React.Children.toArray(children)
const header = slots.find(
(child): child is React.ReactElement => React.isValidElement(child) && child.type === AppLayout.Header
)
const content = slots.filter(
(child) => !React.isValidElement(child) || child.type !== AppLayout.Header
)
return (
<div className="app-layout">
<header>{header}</header>
<main>{content}</main>
</div>
)
}
AppLayout.Header = function Header({ children }: { children: React.ReactNode }) {
return <>{children}</>
}
使用方法:
<AppLayout>
<AppLayout.Header>
<Logo />
<Nav />
</AppLayout.Header>
<Dashboard />
</AppLayout>
10. 最大限の柔軟性を実現するヘッドレスコンポーネント
影響:高 — レンダリング方法に関する制約なしでロジックを提供します。
ヘッドレスコンポーネントは、マークアップを含まずに動作(状態、キーボード処理、ARIA 属性)を提供します。レンダリングは利用者が指定します。
function useToggle(initial = false) {
const [on, setOn] = useState(initial)
const toggle = useCallback(() => setOn(o => !o), [])
const buttonProps = {
'aria-pressed': on,
onClick: toggle,
role: 'switch' as const,
}
return { on, toggle, buttonProps }
}
使用方法 — レンダリングはすべて利用者が制御します:
function DarkModeSwitch() {
const { on, buttonProps } = useToggle(false)
return (
<button {...buttonProps} className={on ? 'dark' : 'light'}>
{on ? 'Dark' : 'Light'} Mode
</button>
)
}
Radix UI、Headless UI、React Aria などのライブラリは、このパターンを採用しています。デザインの柔軟性が必要な場合は、スタイルが全面的に適用されたコンポーネントライブラリより、これらを優先してください。
出典
幅広い React コミュニティ向けのコンポジションガイダンスとして、patterns.dev のパターンを使用しています。