skills/patternsdev/skills/react-composition-2026

react-composition-2026

Installation
SKILL.md

Modern React Composition Patterns

Table of Contents

Composition patterns for building flexible, maintainable React components that scale. These patterns replace boolean-prop proliferation, rigid component APIs, and tangled state with composable, explicit designs.

When to Use

Reference these patterns when:

  • A component has more than 3-4 boolean props controlling its behavior
  • Building reusable UI components or a shared component library
  • Refactoring components that are difficult to extend
  • Designing component APIs that other teams will consume
  • Reviewing component architecture for flexibility and maintainability

Instructions

  • Apply these patterns during component design, code generation, and review. When you see boolean prop accumulation or rigid component APIs, suggest the appropriate composition pattern.

Details

Overview

The core principle: composition over configuration. Instead of adding boolean props and conditional branches to handle every variant, compose smaller, focused components together. This makes components easier to understand, test, and extend — for both humans and AI agents.


1. Replace Boolean Props with Composition

Impact: HIGH — Prevents combinatorial explosion and makes intent explicit.

Boolean props multiply complexity: 4 booleans = 16 possible states, most of which are untested. Replace them with composable children.

Avoid — boolean prop accumulation:

<Card
  showHeader
  showFooter
  collapsible
  bordered
  withShadow
  headerAction="close"
  size="large"
/>

Prefer — explicit composition:

<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>

Each piece is explicit, testable, and independently optional.


2. Build Compound Components with Context

Impact: HIGH — Shared implicit state without prop drilling.

Compound components are a group of components that work together, sharing state through context rather than props. The parent owns the state; children consume it.

Avoid — parent manages everything through 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}
/>

Prefer — compound components:

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

Usage:

<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. Create Explicit Variant Components

Impact: MEDIUM — Makes each mode a clear, focused component.

When a component has distinct "modes" (dialog vs drawer, inline vs modal, card vs list-item), create explicit variant components instead of toggling with props.

Avoid — one component with mode 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} />
}

Prefer — explicit variants:

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} />
}

Each variant has exactly the props it needs — no impossible states, no unused props.


4. Use Children Over Render Props for Composition

Impact: MEDIUM — Simpler API, better readability.

Render props (renderHeader, renderItem) were essential before hooks, but today children provides cleaner composition for most cases.

Avoid — render prop proliferation:

<DataTable
  data={users}
  renderHeader={() => <h2>Users</h2>}
  renderRow={(user) => <UserRow user={user} />}
  renderEmpty={() => <EmptyState />}
  renderFooter={() => <Pagination />}
/>

Prefer — children composition:

<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>

Reserve render props for cases where the parent needs to provide data to the renderer (e.g., virtualized list items).


5. Decouple State Implementation from UI

Impact: MEDIUM — Swap state management without changing components.

Define a generic interface for your state shape (value, actions, metadata), then let providers implement it. Components consume the interface, not the implementation.

Define the interface:

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
}

Implement with local state:

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>
}

Swap to API-backed state without changing consumers:

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>
}

The useCounter() consumers never change.


6. Lift State to Provider Components

Impact: MEDIUM — Enables sibling communication without prop threading.

When two sibling components need shared state, lift it into a provider rather than threading callbacks through the parent.

Avoid — parent threads state to siblings:

function Page() {
  const [selected, setSelected] = useState<string | null>(null)
  return (
    <div>
      <Sidebar selected={selected} onSelect={setSelected} />
      <Detail selected={selected} />
    </div>
  )
}

Prefer — provider manages shared state:

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>
  )
}

Both Sidebar and Detail consume useSelection() directly.


7. Use Polymorphic as Props for Flexible Elements

Impact: MEDIUM — One component, any underlying element or component.

The as prop pattern lets consumers control the rendered element while keeping your component's styles and behavior.

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>
}

Usage:

<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: Drop forwardRef, Use ref as a Prop

Impact: MEDIUM — Simpler component definitions.

React 19 passes ref as a regular prop. No more forwardRef wrapper.

React 18 (deprecated pattern):

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} />
}

Similarly, use() can read either promises or context and can be called conditionally:

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. Slot Pattern for Layout Components

Impact: MEDIUM — Named insertion points without render props.

For layout components with multiple content areas, use a slot pattern based on child type detection or named sub-components.

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}</>
}

Usage:

<AppLayout>
  <AppLayout.Header>
    <Logo />
    <Nav />
  </AppLayout.Header>
  <Dashboard />
</AppLayout>

10. Headless Components for Maximum Flexibility

Impact: HIGH — Logic without opinions about rendering.

Headless components provide behavior (state, keyboard handling, ARIA attributes) without any markup. Consumers supply the rendering.

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 }
}

Usage — consumer controls all rendering:

function DarkModeSwitch() {
  const { on, buttonProps } = useToggle(false)
  return (
    <button {...buttonProps} className={on ? 'dark' : 'light'}>
      {on ? 'Dark' : 'Light'} Mode
    </button>
  )
}

Libraries like Radix UI, Headless UI, and React Aria follow this pattern. Prefer them over fully-styled component libraries when you need design flexibility.


Source

Patterns from patterns.dev — composition guidance for the broader React community.

Weekly Installs
186
GitHub Stars
107
First Seen
Mar 30, 2026
Installed on
gemini-cli185
github-copilot185
antigravity185
opencode185
codex184
kimi-cli184