Designing a Component API
AdvancedCompound components, render props and headless hooks are three answers to the same question: how flexible should this component be, and who decides how it looks?
Overview
A shared component starts with three props and, if the API is wrong, grows to twenty as each new caller needs a variation. The alternative is designing for composition from the start: let callers assemble parts rather than configure a monolith. Compound components share implicit state between related pieces, render props hand control of the markup to the caller, and headless hooks provide the behaviour with no markup at all. Recognising which one a situation wants is a senior skill and a common design-round question.
The Boolean Prop Trap
How a good component becomes unusable, and what to do instead.
// Version 1
<Card title="Two Sum" />
// Eighteen months later
<Card title="Two Sum" showIcon hideFooter compact variant="pro"
headerRight={<Badge />} bodyClassName="p-0" noBorder collapsible />
// Nobody knows which combinations were ever tested.
// Compound components — the caller assembles the parts
<Card>
<Card.Header>
<Card.Title>Two Sum</Card.Title>
<ProBadge />
</Card.Header>
<Card.Body><Description /></Card.Body>
<Card.Footer><SolveButton /></Card.Footer>
</Card>
// Shared state stays implicit, via a private context
const TabsContext = createContext(null)
export function Tabs({ children, defaultValue }) {
const [value, setValue] = useState(defaultValue)
return <TabsContext.Provider value={{ value, setValue }}>{children}</TabsContext.Provider>
}
Tabs.List = TabsList; Tabs.Trigger = TabsTrigger; Tabs.Panel = TabsPanel
// The caller never wires value and onChange between the parts.Render Props and Headless Hooks
Hand over the rendering entirely, or hand over just the behaviour.
// Render prop — the component owns behaviour, the caller owns markup
<DataTable
rows={problems}
renderRow={(row) => <ProblemRow problem={row} />}
renderEmpty={() => <EmptyState />}
/>
// Headless hook — no markup at all, which is usually cleaner today
function useDisclosure(initial = false) {
const [isOpen, setIsOpen] = useState(initial)
return {
isOpen,
open: useCallback(() => setIsOpen(true), []),
close: useCallback(() => setIsOpen(false), []),
toggle: useCallback(() => setIsOpen(v => !v), []),
}
}
const disclosure = useDisclosure()
// This is why headless libraries (Radix, React Aria, TanStack Table)
// took over: they ship the hard behaviour — focus, keyboard, ARIA,
// virtualisation — and impose nothing on your design.
// Higher-order components (withAuth, withRouter) are the older
// answer. You will see them in existing code; write hooks instead.Choosing, and the Rules That Apply Regardless
When each fits, plus the API hygiene reviewers look for.
// One shape, several variants -> props with a variant union
// Related parts sharing state -> compound components
// Same behaviour, different markup -> a headless hook
// One-off flexibility in one spot -> children or a JSX prop
// Regardless of the pattern:
// - forward the rest of the props, so callers can pass aria-*,
// data-* and className without you enumerating them
function Button({ variant = 'primary', className, ...rest }) {
return <button className={cx(styles[variant], className)} {...rest} />
}
// - forward the ref, so callers can focus or measure it
// - keep the DOM semantics: a button must render a <button>
// - controlled AND uncontrolled, where it makes sense:
// value + onChange, or defaultValue for the simple case
// - name props for what they mean, not how they are implemented:
// 'variant', not 'isBlueRoundedLarge'Key Points to Remember
- 1A growing list of boolean props signals that the component should be composed rather than configured
- 2Compound components share state implicitly through a private context, so callers just assemble parts
- 3A headless hook provides behaviour with no markup, which is why headless libraries dominate for complex widgets
- 4Spread remaining props and forward the ref so callers can pass className, aria-* and measure the node
- 5Support both controlled and uncontrolled usage where it makes sense, and name props for meaning not appearance
Interview Questions
Sign in to ask AriaWhat is the compound component pattern and what problem does it solve?
What does "headless" mean for a UI library, and why is it popular?
Why should a shared component spread its remaining props onto the DOM element?
Ask Aria about Designing a Component API
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.