Part 10 · 5 chapters · ~35 min

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.

49

Composition over configuration

the question

"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.

code
// 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> }
the signs a prop should be a child
  1. It takes a node (icon={<X/>}, footer={…}): a slot. Make it a part or a named child.
  2. It takes an array of config objects (actions={[{label, onClick}]}): a list of children. Let the consumer map and render.
  3. 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.)
  4. Two booleans interact (dense and elevated and compact): the component is encoding layouts; give the consumer the parts and let them lay out.
  5. A prop exists for one consumer: the component is leaking a special case; that consumer should compose it differently.
what composition costs
  1. 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.
  2. 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.
  3. Context between parts (next chapter) when parts must share state; a small scoped context is the mechanism.
50

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 construction
  1. The parent owns state (or accepts it as controlled props: value + onChange overriding internal state), creates a stable context value (useMemo), generates shared ids (useId) for ARIA relationships, and renders children unchanged.
  2. 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.
  3. Exports: named parts (Tabs, TabList, Tab, TabPanel) or static properties (Tabs.Tab); the context hook exported too, so consumers can write custom parts.
  4. Variants without props: a vertical tab list is CSS on TabList; a tab that is a link is a custom part using useTabsContext; a lazy panel is TabPanel with a lazy child.
the mechanics that matter
  1. 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.
  2. 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.
  3. 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).
  4. Keys and identity: parts identified by a value prop, not by index, so reordering tabs does not break selection.
A COMPOUND COMPONENT
Tabs: shared state through a scoped context, structure through children
swipe the figure sideways, or tap expand for full screen
1/6
the consumer
The consumer writes: OneTwo
……
. Any structure between the parts; no index props; no render callbacks.
51

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.

headless, the contract
  1. Input: items, selection, callbacks, and configuration of behaviour (multi-select, type-ahead, close on select).
  2. 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.
  3. 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.
  4. 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.
polymorphic, the two ways
  1. as prop: <Button as="a" href> or <Button as={Link} to>. The component renders Comp = as ?? 'button' and spreads its behaviour props. Typing it so that the props of as are checked (a polymorphic component type) is the hard part and is why libraries publish a PolymorphicComponentProps helper or avoid it.
  2. 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.
  3. 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.
the trade
Headless costs the consumer markup and vigilance; it buys correctness that survives restyling. For a product with one look, a styled wrapper over a headless primitive gives both: the primitive's behaviour, the product's markup, written once.
HEADLESS: LOGIC WITHOUT MARKUP
a hook that returns props to spread, and what that separates
swipe the figure sideways, or tap expand for full screen
1/6
the hook
useCombobox({ items, onSelectedItemChange }) returns { getInputProps, getMenuProps, getItemProps, getToggleButtonProps, isOpen, highlightedIndex, selectedItem }. No elements; functions that produce props.
52

Render props, slots, and state colocation

code
// 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.
render props, where they remain right
  1. 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).
  2. Stability: the function is a prop; a new function each parent render makes the component re-create the rendered elements. useCallback it (or let the Compiler), and keep its dependencies minimal.
  3. 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).
slots
  1. Named children: title, footer, actions as node props when order is fixed by the component; compound parts when the consumer should control order and wrapping.
  2. Default content: {footer ?? <DefaultFooter/>}; or a part that renders a default when empty.
  3. 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).
code
// 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
colocation, as a pattern
  1. 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).
  2. 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).
  3. The measurement: the Profiler's "why did this render" on the expensive sibling before and after.
53

The patterns, in one table

PatternMechanismUse whenAvoid whenCost
Composition over configurationChildren and parts instead of propsA component has variations its author cannot enumerateThe component has one shape and three propsMore JSX per use; add a preset for the common case
Compound componentsParent state via a scoped context; parts read itParts must coordinate (tabs, menus, selects, accordions, forms)There is one partA small context; a hook per part; controlled/uncontrolled plumbing
HeadlessBehaviour returns props; consumer rendersOne behaviour, many looks; accessibility must be rightOne look, one place; a styled component is simplerConsumer must spread correctly; merging props
Polymorphic (as / asChild)Render as the consumer's element; merge propsA behaviour applies to links, buttons, custom elementsThe element is always the sameTyping (as) or cloning (asChild); ref and handler merging
Render propsA function prop called at a position the component ownsPer-item rendering under the component's control (lists, tables, charts)Sharing logic (use a hook)Function stability; nesting
HooksLogic returned as values; consumer rendersSharing stateful logic between componentsThe logic must render into a controlled positionThe rules of hooks; no wrapper element
SlotsNode props or parts for regionsFixed regions with consumer content; RSC chrome around server contentThe region's order should be free (use parts)None beyond naming
State colocationState in its lowest owner; split draft from committedAlwaysNeverA refactor when state was lifted early
Controlled / uncontrolled dual modevalue ?? internal; onChange mirrorsLibrary components; any part with both use casesApp-internal components used one wayThe idiom; a mode-switch warning
Provider compositionNesting providers in one component; a <Providers> wrapperApp roots with many contextsA provider per feature is cleaner than a global pyramidA re-render of all providers' values if one changes without memo
Container / presentational (legacy)Data components wrapping view componentsMostly replaced by hooks and server componentsBy default nowAn extra layer that hooks made unnecessary
Higher-order components (legacy)A function wrapping a componentCross-cutting concerns in class-era code; some library APIsNew code (use hooks or compound parts)Wrapper hell; ref forwarding; displayName
the pointer
Part 11 is the anti-patterns: prop drilling, context overuse, effect chains, derived state in state, premature memo. Each is one of these patterns applied where another was needed, and the table above is the lookup for the fix.