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.
Table of Contents
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 Components | Structural UI with multiple related parts | Requires Context; harder to render sub-components outside parent |
| Render Props | Sharing computed state with flexible rendering | Callback nesting can get deep; prefer hooks instead in React 18+ |
| Custom Hooks | Logic reuse without component coupling | No default UI — caller must build everything |
| Headless Components | Design-system libraries (Radix, Headless UI) | More boilerplate per usage site |