mcpField
R
react-composition-2026

react-composition-2026

github.com/patternsdev開発2571.1k 回インストール28 週間前に更新
リポジトリ

react-composition-2026 について

柔軟な React コンポーネント API の設計、リファクタリング、レビューに役立つコンポジションパターンのガイドです。複合コンポーネントや明示的なバリアント、状態の分離に加え、React 19 の ref の使い方も扱います。

主な機能

  1. 01ブール値 prop の増加をコンポジションで回避
  2. 02Context による複合コンポーネント
  3. 03明示的なバリアントコンポーネント
  4. 04children によるコンポジション
  5. 05状態の実装と UI の分離
  6. 06ポリモーフィズム、スロット、ヘッドレスコンポーネント

活用シーン

  1. 01拡張しにくいコンポーネントのリファクタリング
  2. 02再利用可能なコンポーネントライブラリの API 設計
  3. 03ブール値 prop が多いコンポーネントのレビュー
  4. 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 のパターンを使用しています。

コメント