ResourcesReact PatternsThe Compound Component Pattern: React's Most Elegant Design
⚛️React PatternsThe Compound Component Pattern: React's Most Elegant Design7 min

The Compound Component Pattern: React's Most Elegant Design

The compound component pattern builds APIs that feel like HTML, flexible, readable, and impossible to use wrong. Here's how to implement it.

📅February 1, 2026TechTwitter.ioreactpatternscompound-componentsdesign

The Problem with Prop Drilling

Most developers build components like this:

<Select
  options={options}
  value={selected}
  onChange={setSelected}
  placeholder="Select an item"
  searchable={true}
  renderOption={(opt) => <span>{opt.label}</span>}
  renderSelected={(opt) => <strong>{opt.label}</strong>}
  groupBy="category"
  maxHeight={300}
  isLoading={loading}
/>

15 props. Hard to use, hard to extend, impossible to predict what's needed. This is prop proliferation.


The Compound Component Approach

Compound components split a complex widget into composable subcomponents that communicate implicitly via context:

<Select value={selected} onChange={setSelected}>
  <Select.Trigger placeholder="Select an item" />
  <Select.Content maxHeight={300}>
    <Select.Search />
    {options.map(opt => (
      <Select.Option key={opt.value} value={opt.value}>
        {opt.label}
      </Select.Option>
    ))}
  </Select.Content>
</Select>

This is how HTML's <select> / <option> work. The API is discoverable, flexible, and readable.


Building the Pattern

Step 1: Create the context

interface SelectContextValue {
  value: string | null
  onChange: (value: string) => void
  isOpen: boolean
  setIsOpen: (open: boolean) => void
}

const SelectContext = createContext<SelectContextValue | null>(null)

function useSelect() {
  const ctx = useContext(SelectContext)
  if (!ctx) throw new Error('Select subcomponent used outside <Select />')
  return ctx
}

Step 2: Create the parent component

interface SelectProps {
  value: string | null
  onChange: (value: string) => void
  children: React.ReactNode
}

function Select({ value, onChange, children }: SelectProps) {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <SelectContext.Provider value={{ value, onChange, isOpen, setIsOpen }}>
      <div className="select-container">
        {children}
      </div>
    </SelectContext.Provider>
  )
}

Step 3: Create the subcomponents

Select.Trigger = function Trigger({ placeholder }: { placeholder?: string }) {
  const { value, isOpen, setIsOpen } = useSelect()
  return (
    <button
      onClick={() => setIsOpen(!isOpen)}
      aria-expanded={isOpen}
      className="select-trigger"
    >
      {value ?? placeholder ?? 'Select...'}
    </button>
  )
}

Select.Content = function Content({
  children,
  maxHeight = 200,
}: { children: React.ReactNode; maxHeight?: number }) {
  const { isOpen } = useSelect()
  if (!isOpen) return null
  return (
    <div className="select-content" style={{ maxHeight }}>
      {children}
    </div>
  )
}

Select.Option = function Option({
  value,
  children,
}: { value: string; children: React.ReactNode }) {
  const { value: selected, onChange, setIsOpen } = useSelect()
  return (
    <div
      className={`select-option ${value === selected ? 'selected' : ''}`}
      onClick={() => { onChange(value); setIsOpen(false) }}
      role="option"
      aria-selected={value === selected}
    >
      {children}
    </div>
  )
}

Select.Search = function Search() {
  const [search, setSearch] = useState('')
  // filter children based on search — implementation depends on your approach
  return (
    <input
      value={search}
      onChange={e => setSearch(e.target.value)}
      placeholder="Search..."
      className="select-search"
    />
  )
}

Step 4: Usage

const [country, setCountry] = useState<string | null>(null)

<Select value={country} onChange={setCountry}>
  <Select.Trigger placeholder="Choose country" />
  <Select.Content maxHeight={250}>
    <Select.Search />
    <Select.Option value="us">United States</Select.Option>
    <Select.Option value="uk">United Kingdom</Select.Option>
    <Select.Option value="ca">Canada</Select.Option>
  </Select.Content>
</Select>

Real-World Examples

This pattern is used by:

  • Radix UI<Dialog.Root>, <Dialog.Trigger>, <Dialog.Content>
  • Headless UI<Listbox>, <Listbox.Button>, <Listbox.Options>
  • React Aria — similar composable primitives
  • HTML itself — <table>, <tr>, <td>

When to Use This Pattern

✅ Complex UI widgets with multiple configurable parts ✅ Components that need flexibility without prop explosion ✅ Shared component libraries where users need control over markup

❌ Simple components that only need 1-2 props — overkill ❌ When you don't need the subcomponents to be individually configurable


Key Takeaways

  • Compound components split complex widgets into composable subcomponents
  • They communicate via React context — no prop drilling required
  • The pattern looks like HTML: <Select>, <Select.Option> vs <select>, <option>
  • Always throw a descriptive error in the context hook when used outside the parent
  • Used by Radix, Headless UI, and HTML itself — it's a proven pattern for component libraries