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