Patterns
The patterns that last move decisions from props to composition: parts that share a scoped context, behaviour that returns props for the consumer to render, slots and render props for regions, state in its lowest owner. This part is each with its mechanism, its construction, and the moment it is the wrong tool, ending in one table.
Composition over configuration
"The Card component has 23 props and three of them are objects describing other components. Where did it go wrong?"
At the first prop that described a child. A configuration API grows a prop per variation and a branch per prop; combinations multiply and the component becomes a switchboard for cases its author imagined. A composition API provides structure (parts, slots, context) and lets the consumer supply the content as children; variations that were never imagined are just different JSX. The patterns in this part are all ways of moving decisions from props to composition.
// composition over configuration: the prop that was a boolean becomes a child
// configuration: every variation is a prop; the component grows a switch per prop; combinations explode
<Card title="Balance" icon={<Wallet/>} actions={[{ label: 'Top up', onClick }]} footer="Updated 2m ago" variant="elevated" dense />
// composition: the component provides structure; the consumer fills it
<Card variant="elevated">
<Card.Header icon={<Wallet/>}>Balance</Card.Header>
<Card.Body><Amount value={balance} /></Card.Body>
<Card.Actions><Button onClick={topUp}>Top up</Button></Card.Actions>
<Card.Footer><Relative time={updatedAt} /></Card.Footer>
</Card>
// the second has no "footer as a string or a node?" question, no "actions array shape", no dense prop that half the parts ignore.
// rule of thumb: a prop that takes a node, an array of config objects, or a render function is a slot; make it children of a part instead.
// slots in React are just named children: either compound parts (above) or explicit slot props when order must be fixed:
function Dialog({ title, children, footer }) { return <div role="dialog"><h2>{title}</h2><div>{children}</div><div>{footer}</div></div> }- It takes a node (
icon={<X/>},footer={…}): a slot. Make it a part or a named child. - It takes an array of config objects (
actions={[{label, onClick}]}): a list of children. Let the consumer map and render. - It takes a render function for a region the consumer owns: a slot. (For a region the component owns per item, a render prop is right: chapter 4.)
- Two booleans interact (
denseandelevatedandcompact): the component is encoding layouts; give the consumer the parts and let them lay out. - A prop exists for one consumer: the component is leaking a special case; that consumer should compose it differently.
- More JSX at the call site for the simple case. Mitigate with a thin preset (
<SimpleCard title body />that composes the parts) for the common shape, built on the composable one. - Structure is the consumer's responsibility; a part in the wrong place renders in the wrong place. Parts that must be ordered can enforce it (slot props) or document it.
- Context between parts (next chapter) when parts must share state; a small scoped context is the mechanism.
Compound components
A compound component is a parent and its parts, sharing state through a context scoped to the parent, composed by the consumer with ordinary JSX. The consumer controls structure and content; the parent controls behaviour. It is the pattern under every accessible UI library (tabs, menus, dialogs, selects, accordions) and the right shape for any widget whose parts must coordinate.
- The parent owns state (or accepts it as controlled props:
value+onChangeoverriding internal state), creates a stable context value (useMemo), generates shared ids (useId) for ARIA relationships, and renderschildrenunchanged. - Each part reads the context with a hook that throws a clear error outside the parent (
useTabsContext()), renders a real element with the right role and attributes, and updates through the context's setters. - Exports: named parts (
Tabs,TabList,Tab,TabPanel) or static properties (Tabs.Tab); the context hook exported too, so consumers can write custom parts. - Variants without props: a vertical tab list is CSS on
TabList; a tab that is a link is a custom part usinguseTabsContext; a lazy panel isTabPanelwith a lazy child.
- Context scope: the provider is the parent, so consumers are only the parts under it. "Every consumer renders on change" (part 2) is a handful of elements: cheap. This is the one legitimate use of context for frequently changing state.
- The consumer's elements bail out: wrappers and content the consumer put between parts are elements the consumer created; the parent re-rendering does not recreate them (they are
children, unchanged references), so they skip. - Controlled and uncontrolled:
const [internal, setInternal] = useState(defaultValue); const value = props.value ?? internal; const set = v => { setInternal(v); props.onChange?.(v) }: the standard dual-mode idiom. Switching modes after mount warns (same as inputs). - Keys and identity: parts identified by a
valueprop, not by index, so reordering tabs does not break selection.
Headless and polymorphic components
Headless separates behaviour from markup: a hook or primitive implements the state machine and accessibility of a widget and hands back props to spread; the consumer renders whatever elements and styles they want. Polymorphic lets one component render as different elements (as or asChild) while keeping its behaviour. Together they are how a design system ships one combobox that is a hundred different-looking comboboxes.
- Input: items, selection, callbacks, and configuration of behaviour (multi-select, type-ahead, close on select).
- Output: state (
isOpen,highlightedIndex,selectedItems) and prop getters (getInputProps(),getItemProps({ item, index })) whose objects contain the ids, ARIA attributes, event handlers and refs that make the widget correct. - The consumer's obligation: spread every getter onto the right element and merge their own handlers through the getter (
getItemProps({ onClick: mine })) rather than overriding. - Where the hard parts live: the keyboard model, focus management, the blur-versus-click race, scroll-into-view, screen reader announcements, touch, RTL. In the library, tested once.
asprop:<Button as="a" href>or<Button as={Link} to>. The component rendersComp = as ?? 'button'and spreads its behaviour props. Typing it so that the props ofasare checked (a polymorphic component type) is the hard part and is why libraries publish aPolymorphicComponentPropshelper or avoid it.asChild/ Slot:<Button asChild><Link to>Go</Link></Button>. The component clones its single child, merging its own props (handlers composed, classes concatenated, refs merged) onto it. No generic typing problem; the child's own props are already typed. Radix's choice.- What both must handle: ref merging (the consumer's ref and the behaviour's ref on one node), event handler composition (both run, in order, unless one calls
preventDefault), class and style merging.
Render props, slots, and state colocation
// render props and hooks: the same inversion, two syntaxes
// render prop: the component owns state and calls a function to render
<MouseTracker>{({ x, y }) => <Cursor x={x} y={y} />}</MouseTracker>
// hook: the state is returned; the consumer renders
const { x, y } = useMouse(); return <Cursor x={x} y={y} />
// hooks replaced render props for sharing logic (no wrapper element, no callback nesting, composable in one function body).
// render props remain for: rendering INTO a position the component controls (a list's row renderer, a table's cell renderer, a virtualiser's item):
<FixedSizeList itemCount={n} itemSize={32}>{({ index, style }) => <Row style={style} item={items[index]} />}</FixedSizeList>
// the row renderer must be stable (useCallback) or the list re-creates every row element per parent render.
// "children as a function" is a render prop by another name. a slot is a render prop that takes a node instead of a function.- Rendering into a position the component controls per item: a virtualised list's row, a table's cell, a chart's tooltip, a tree's node. The component decides when and where; the consumer decides what. A hook cannot do this (it has no position).
- Stability: the function is a prop; a new function each parent render makes the component re-create the rendered elements.
useCallbackit (or let the Compiler), and keep its dependencies minimal. - Children as a function is the same with the function in the children position: readable for one slot, confusing for several (use named props then).
- Named children:
title,footer,actionsas node props when order is fixed by the component; compound parts when the consumer should control order and wrapping. - Default content:
{footer ?? <DefaultFooter/>}; or a part that renders a default when empty. - In RSC: a client component with slots filled by server-rendered nodes is the main way to wrap static content in interactive chrome without shipping the content's code (part 6).
// state colocation and the "lift once" rule, as a refactor
// before: the page owns everything; every keystroke in the search box re-renders the page and the table
function Page() { const [query, setQuery] = useState(''); const [sort, setSort] = useState('name'); const rows = useRows()
return <><Search value={query} onChange={setQuery} /><Table rows={filterSort(rows, query, sort)} sort={sort} onSort={setSort} /></> }
// after: Search owns the draft; the page owns only the committed query (debounced or on submit); sort lives with the table
function Page() { const [query, setQuery] = useState(''); const rows = useRows()
return <><Search onCommit={setQuery} /><Table rows={rows} query={query} /></> }
function Search({ onCommit }) { const [draft, setDraft] = useState(''); const commit = useDebounced(onCommit, 200); return <input value={draft} onChange={e => { setDraft(e.target.value); commit(e.target.value) }} /> }
function Table({ rows, query }) { const [sort, setSort] = useState('name'); const visible = useMemo(() => filterSort(rows, query, sort), [rows, query, sort]); /* … */ }
// what moved: the keystroke-rate state down into Search (one small render per key); the sort into the only component that uses it;
// the page keeps the one value two siblings need. the Profiler's "why did this render" on Table goes from "parent rendered" per key to nothing.
// headless + polymorphic: a component that renders as whatever element the consumer asks for, keeping its behaviour
function Button({ as: Comp = 'button', ...props }) { return <Comp {...useButtonBehaviour(props)} /> } // <Button as={Link} to="/x">
// typing `as` correctly is the hard part (polymorphic component types); libraries (Radix's Slot / asChild) avoid the generic by merging onto the child- State in the lowest owner; keystroke-rate state in the field; the committed value one level up; feature state in the feature; cross-route state in a store (part 7).
- The refactor is mechanical: find the setState that fires most; find the components that read it; move it to their lowest common ancestor; if that ancestor is the one that was already too high, split the state (draft versus committed).
- The measurement: the Profiler's "why did this render" on the expensive sibling before and after.
The patterns, in one table
| Pattern | Mechanism | Use when | Avoid when | Cost |
|---|---|---|---|---|
| Composition over configuration | Children and parts instead of props | A component has variations its author cannot enumerate | The component has one shape and three props | More JSX per use; add a preset for the common case |
| Compound components | Parent state via a scoped context; parts read it | Parts must coordinate (tabs, menus, selects, accordions, forms) | There is one part | A small context; a hook per part; controlled/uncontrolled plumbing |
| Headless | Behaviour returns props; consumer renders | One behaviour, many looks; accessibility must be right | One look, one place; a styled component is simpler | Consumer must spread correctly; merging props |
| Polymorphic (as / asChild) | Render as the consumer's element; merge props | A behaviour applies to links, buttons, custom elements | The element is always the same | Typing (as) or cloning (asChild); ref and handler merging |
| Render props | A function prop called at a position the component owns | Per-item rendering under the component's control (lists, tables, charts) | Sharing logic (use a hook) | Function stability; nesting |
| Hooks | Logic returned as values; consumer renders | Sharing stateful logic between components | The logic must render into a controlled position | The rules of hooks; no wrapper element |
| Slots | Node props or parts for regions | Fixed regions with consumer content; RSC chrome around server content | The region's order should be free (use parts) | None beyond naming |
| State colocation | State in its lowest owner; split draft from committed | Always | Never | A refactor when state was lifted early |
| Controlled / uncontrolled dual mode | value ?? internal; onChange mirrors | Library components; any part with both use cases | App-internal components used one way | The idiom; a mode-switch warning |
| Provider composition | Nesting providers in one component; a <Providers> wrapper | App roots with many contexts | A provider per feature is cleaner than a global pyramid | A re-render of all providers' values if one changes without memo |
| Container / presentational (legacy) | Data components wrapping view components | Mostly replaced by hooks and server components | By default now | An extra layer that hooks made unnecessary |
| Higher-order components (legacy) | A function wrapping a component | Cross-cutting concerns in class-era code; some library APIs | New code (use hooks or compound parts) | Wrapper hell; ref forwarding; displayName |