A11 · Part 3 · Advanced
Advanced component patterns
The four shapes behind every component library you have imported.
08On this page
Compound components
<Tabs defaultValue="a">
<Tabs.List>
<Tabs.Trigger value="a">First</Tabs.Trigger>
<Tabs.Trigger value="b">Second</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="a">...</Tabs.Panel>
</Tabs>
The parent owns the state and shares it through context; the children are
layout the caller arranges. Compare with the alternative —
<Tabs items={[...]} renderPanel={...} activeIndex={...} />
— which needs a new prop for every arrangement anyone ever wants.
const TabsContext = createContext(null);
function Tabs({ defaultValue, children }) {
const [value, setValue] = useState(defaultValue);
const ctx = useMemo(() => ({ value, setValue }), [value]);
return <TabsContext value={ctx}>{children}</TabsContext>;
}
Tabs.Trigger = function Trigger({ value, children }) {
const ctx = useContext(TabsContext);
return (
<button role="tab" aria-selected={ctx.value === value}
onClick={() => ctx.setValue(value)}>{children}</button>
);
};
The trade is that the children are coupled to the parent by context, so using
Tabs.Trigger outside Tabs must fail with a clear
error. That is what the throwing custom hook from
the context chapter is for.
Headless components
function useDisclosure(initial = false) {
const [isOpen, setOpen] = useState(initial);
return {
isOpen,
open: useCallback(() => setOpen(true), []),
close: useCallback(() => setOpen(false), []),
triggerProps: { "aria-expanded": isOpen, onClick: () => setOpen((v) => !v) },
};
}
Behaviour, accessibility and state with no markup at all — you render whatever you want and spread the props. This is the model behind Radix, Headless UI, TanStack Table and React Aria, and it is the answer to "a component library that never matches our design".
The hard part of a dropdown was never the markup. It is roving focus, typeahead, Escape, click-outside, aria attributes, and returning focus on close. A headless library gives you exactly that and leaves the appearance alone.
Slots and asChild
<Button asChild>
<Link to="/settings">Settings</Link> // renders one anchor, styled as a button
</Button>
Without this, a button-styled link is either a nested
<button><a></a></button> — invalid
HTML and broken for keyboards — or a duplicated set of styles.
asChild merges the component's props onto the child element
instead of rendering a wrapper.
Controlled and uncontrolled, in one component
function useControllable({ value, defaultValue, onChange }) {
const [uncontrolled, setUncontrolled] = useState(defaultValue);
const isControlled = value !== undefined;
return [
isControlled ? value : uncontrolled,
(next) => {
if (!isControlled) setUncontrolled(next);
onChange?.(next);
},
];
}
Every serious library supports both: defaultValue for the easy
case, value plus onChange when the caller needs
control. The rule is that a component must not switch modes during its life
— going from undefined to a value is the React warning
everyone has seen.
Render props, and why hooks replaced most of them
<MouseTracker render={({ x, y }) => <Dot x={x} y={y} />} /> // old
const { x, y } = useMouse(); // now
Hooks share logic without adding tree depth, so most render props became hooks. They survive where the parent must control when and how often the children render — a virtualised list rendering only visible rows, or a component that renders its children twice for measurement.
The element APIs you will meet in older code
Children.map(children, (child) => ...) // safe iteration, handles one child or many
Children.count(children)
isValidElement(child) // is this an element or a string?
cloneElement(child, { extra: "prop" }) // copy with props merged in
Before context and hooks, a parent injected behaviour into its children by cloning them:
function RadioGroup({ value, onChange, children }) {
return Children.map(children, (child) =>
cloneElement(child, { checked: child.props.value === value, onChange })
);
}
It works, and it is fragile: it only reaches direct children, so wrapping one
in a <div> silently breaks it, and the props a child
receives are invisible at the call site. Context does the same job through any
depth, which is why compound components
replaced this pattern.
Learn these to read older code and a few libraries, not to write new components with.
Higher-order components
const Enhanced = withAuth(withTheme(withRouter(MyComponent))); // where does a prop come from?
Largely historical. They obscure prop origins, stack up in DevTools, and
require care with displayName and static hoisting. You will meet
them in older codebases and in a few libraries; you should not write new ones.
Choosing
| You want | Pattern |
|---|---|
| Callers to arrange the pieces | Compound components |
| Behaviour without imposing markup | Headless hook |
| Styling applied to somebody else's element | asChild / slot |
| Both easy defaults and full control | Controllable state |
| To control when children render | Render prop |
| To share logic | A custom hook, every time |
Interview answer, one sentence
"Compound components move layout back to the caller, headless hooks give you
the hard part — focus, keyboard, aria — without imposing markup, and
asChild avoids the invalid nesting you get styling a link as a
button. Render props survive only where the parent must control when
children render; everything else about sharing logic is a hook now."
Opens in the editor: write it, run it, and check it against real tests.
All 54 for this level →Finished reading?
0 of 57 chapters read in this series