React Compound Components and Render Props Patterns (2026)

Compound components and render props solve the same problem from different angles: how do you build a reusable component with flexible internals that callers can customize without prop-drilling nightmares? Compound components share implicit state via Context; render props expose that state through function children. Together they are the foundation of every great headless component library.

Basic Compound Components

Compound components are a set of components that work together, sharing implicit state through the parent:

// Without compound components — prop nightmare
<Select
  options={options}
  renderOption={(opt) => ...}
  renderTrigger={(selected) => ...}
  renderEmpty={() => ...}
  groupBy="category"
/>

// With compound components — intuitive, flexible
<Select value={value} onChange={setValue}>
  <Select.Trigger>{value || 'Choose...'}</Select.Trigger>
  <Select.Options>
    <Select.Group label="Fruits">
      <Select.Option value="apple">🍎 Apple</Select.Option>
      <Select.Option value="banana">🍌 Banana</Select.Option>
    </Select.Group>
    <Select.Empty>No options found</Select.Empty>
  </Select.Options>
</Select>

Context-Based Compound Components

import { createContext, useContext, useState, ReactNode } from 'react'

// 1. Create context for shared state
interface TabsContextValue {
  activeTab: string
  setActiveTab: (tab: string) => void
}

const TabsContext = createContext<TabsContextValue | null>(null)

function useTabs() {
  const ctx = useContext(TabsContext)
  if (!ctx) throw new Error('useTabs must be used inside <Tabs>')
  return ctx
}

// 2. Parent holds state, provides it via context
function Tabs({ children, defaultTab }: { children: ReactNode; defaultTab: string }) {
  const [activeTab, setActiveTab] = useState(defaultTab)
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  )
}

// 3. Sub-components consume context
function TabList({ children }: { children: ReactNode }) {
  return <div className="tab-list" role="tablist">{children}</div>
}

function Tab({ value, children }: { value: string; children: ReactNode }) {
  const { activeTab, setActiveTab } = useTabs()
  const isActive = activeTab === value

  return (
    <button
      role="tab"
      aria-selected={isActive}
      className={`tab ${isActive ? 'tab-active' : ''}`}
      onClick={() => setActiveTab(value)}
    >
      {children}
    </button>
  )
}

function TabPanel({ value, children }: { value: string; children: ReactNode }) {
  const { activeTab } = useTabs()
  if (activeTab !== value) return null
  return <div role="tabpanel">{children}</div>
}

// 4. Attach sub-components as static properties
Tabs.List = TabList
Tabs.Tab = Tab
Tabs.Panel = TabPanel

// Usage
function App() {
  return (
    <Tabs defaultTab="overview">
      <Tabs.List>
        <Tabs.Tab value="overview">Overview</Tabs.Tab>
        <Tabs.Tab value="analytics">Analytics</Tabs.Tab>
        <Tabs.Tab value="settings">Settings</Tabs.Tab>
      </Tabs.List>
      <Tabs.Panel value="overview"><OverviewContent /></Tabs.Panel>
      <Tabs.Panel value="analytics"><AnalyticsContent /></Tabs.Panel>
      <Tabs.Panel value="settings"><SettingsContent /></Tabs.Panel>
    </Tabs>
  )
}

TypeScript Generics

// Generic Select with type-safe values
interface SelectContextValue<T> {
  value: T | null
  onChange: (value: T) => void
  isOpen: boolean
  setIsOpen: (open: boolean) => void
}

function createSelectContext<T>() {
  const Context = createContext<SelectContextValue<T> | null>(null)
  const useSelectContext = () => {
    const ctx = useContext(Context)
    if (!ctx) throw new Error('Must be used inside Select')
    return ctx
  }
  return [Context, useSelectContext] as const
}

const [SelectContext, useSelectContext] = createSelectContext<string>()

// Option knows its value type matches the parent's
function SelectOption({ value, children }: { value: string; children: ReactNode }) {
  const { onChange, setIsOpen } = useSelectContext()
  return (
    <button
      role="option"
      onClick={() => { onChange(value); setIsOpen(false) }}
    >
      {children}
    </button>
  )
}

Render Props Pattern

Render props expose component state to callers via a function — great when the parent can't enumerate all consumers at design time:

// Mouse tracker with render props
interface MousePosition {
  x: number
  y: number
}

function MouseTracker({ render }: { render: (pos: MousePosition) => ReactNode }) {
  const [pos, setPos] = useState<MousePosition>({ x: 0, y: 0 })

  return (
    <div
      style={{ height: '400px', border: '1px solid #ccc' }}
      onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}
    >
      {render(pos)}
    </div>
  )
}

// Consumer decides how to use the state
<MouseTracker
  render={({ x, y }) => (
    <div>
      <img
        src="/cat.png"
        style={{ position: 'absolute', left: x, top: y }}
        alt="Cat follows cursor"
      />
    </div>
  )}
/>

// "children as function" — identical pattern, different API surface
function Toggle({ children }: { children: (state: { on: boolean; toggle: () => void }) => ReactNode }) {
  const [on, setOn] = useState(false)
  return <>{children({ on, toggle: () => setOn(v => !v) })}</>
}

<Toggle>
  {({ on, toggle }) => (
    <div>
      <button onClick={toggle}>{on ? 'ON' : 'OFF'}</button>
      {on && <p>Panel is visible</p>}
    </div>
  )}
</Toggle>

Headless Components

Headless components separate logic from presentation entirely — the caller provides all the UI:

// Headless Combobox — logic only, no styles
interface ComboboxState<T> {
  inputValue: string
  filteredItems: T[]
  selectedItem: T | null
  isOpen: boolean
  highlightedIndex: number
  getInputProps: () => Record<string, unknown>
  getItemProps: (item: T, index: number) => Record<string, unknown>
  getMenuProps: () => Record<string, unknown>
}

function useCombobox<T>({
  items,
  filterFn,
  onChange,
}: {
  items: T[]
  filterFn: (items: T[], query: string) => T[]
  onChange?: (item: T) => void
}): ComboboxState<T> {
  const [inputValue, setInputValue] = useState('')
  const [isOpen, setIsOpen] = useState(false)
  const [selectedItem, setSelectedItem] = useState<T | null>(null)
  const [highlightedIndex, setHighlightedIndex] = useState(-1)

  const filteredItems = filterFn(items, inputValue)

  return {
    inputValue,
    filteredItems,
    selectedItem,
    isOpen,
    highlightedIndex,
    getInputProps: () => ({
      value: inputValue,
      onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
        setInputValue(e.target.value)
        setIsOpen(true)
      },
      onFocus: () => setIsOpen(true),
    }),
    getItemProps: (item: T, index: number) => ({
      onMouseEnter: () => setHighlightedIndex(index),
      onClick: () => {
        setSelectedItem(item)
        onChange?.(item)
        setIsOpen(false)
      },
    }),
    getMenuProps: () => ({ role: 'listbox' }),
  }
}

Real-World: Accordion

const AccordionContext = createContext<{ openItems: Set<string>; toggle: (id: string) => void } | null>(null)

function Accordion({ children, multiple = false }: { children: ReactNode; multiple?: boolean }) {
  const [openItems, setOpenItems] = useState<Set<string>>(new Set())

  const toggle = (id: string) => {
    setOpenItems(prev => {
      const next = new Set(multiple ? prev : [])  // single vs multi
      prev.has(id) ? next.delete(id) : next.add(id)
      return next
    })
  }

  return (
    <AccordionContext.Provider value={{ openItems, toggle }}>
      <div className="accordion">{children}</div>
    </AccordionContext.Provider>
  )
}

function AccordionItem({ id, title, children }: { id: string; title: string; children: ReactNode }) {
  const { openItems, toggle } = useContext(AccordionContext)!
  const isOpen = openItems.has(id)

  return (
    <div className="accordion-item">
      <button className="accordion-header" onClick={() => toggle(id)} aria-expanded={isOpen}>
        {title}
        <span aria-hidden>{isOpen ? '▲' : '▼'}</span>
      </button>
      {isOpen && <div className="accordion-body">{children}</div>}
    </div>
  )
}

Accordion.Item = AccordionItem

// Usage
<Accordion multiple>
  <Accordion.Item id="faq-1" title="What is React?">React is a UI library.</Accordion.Item>
  <Accordion.Item id="faq-2" title="What are hooks?">Functions for state and side effects.</Accordion.Item>
</Accordion>

Pattern Comparison

Pattern Best For Trade-off
Compound ComponentsStructural UI with multiple related partsRequires Context; harder to render sub-components outside parent
Render PropsSharing computed state with flexible renderingCallback nesting can get deep; prefer hooks instead in React 18+
Custom HooksLogic reuse without component couplingNo default UI — caller must build everything
Headless ComponentsDesign-system libraries (Radix, Headless UI)More boilerplate per usage site