Hooks as the headless pattern
A custom hook is the most ergonomic headless pattern: useX() returns state, handlers, and prop-getters the caller spreads onto its own markup — logic reuse with no wrapper, no nesting, full markup freedom. The failure mode is a hook that returns JSX.
Render props solved logic reuse by passing a function as children, but they cost you a wrapper component and a pyramid of nesting — three behaviours stacked means three render-prop wrappers wrapping each other, and your actual markup buried at the bottom. The behaviour was reusable; the ergonomics were not.
The custom hook took that same idea — separate behaviour from markup — and dropped the wrapper entirely. A useDisclosure() returns the open/closed state, the handlers, and the accessibility props your button and panel need; you spread them onto your own JSX. Same headless logic, zero nesting, total markup freedom. This is why hooks are the modern successor to render props for logic reuse — and knowing the line where a “headless hook” earns its keep is the senior skill.
After this lesson you can build a headless custom hook that returns state, handlers, and prop-getters (getButtonProps, getPanelProps) for the caller to spread onto its own elements; explain why hooks-as-headless beats render props for logic reuse (no wrapper, no nesting, no markup constraint); recognise the two failure modes — a hook that returns JSX (that’s a component, not a headless hook) and extracting a headless hook with a single caller where inline code is simpler; and decide which one a real situation is.
A headless hook returns behaviour, never markup — useX() gives you state plus the handlers and props to wire up, and you render whatever you want. “Headless” means the logic has no opinion about the DOM. The hook owns what (open/closed, toggle) and the caller owns how (a <button>, a <details>, a custom card). Start with the minimal shape: state and the actions that change it.
import { useCallback, useState } from "react";
function useDisclosure(initial = false) {
const [isOpen, setOpen] = useState(initial);
const open = useCallback(() => setOpen(true), []);
const close = useCallback(() => setOpen(false), []);
const toggle = useCallback(() => setOpen((v) => !v), []);
return { isOpen, open, close, toggle };
}That alone is reusable across a dialog, a dropdown, an accordion row. Nothing in it knows or cares what element you’ll attach toggle to — which is exactly the point of headless.
Prop-getters are the upgrade that makes a headless hook ergonomic: they bundle the wiring — onClick, aria-expanded, id — so the caller spreads one object instead of remembering five attributes. A getter is a function that returns the props an element needs, and crucially it composes the caller’s own handlers rather than overwriting them.
function useDisclosure(initial = false) {
const [isOpen, setOpen] = useState(initial);
const toggle = useCallback(() => setOpen((v) => !v), []);
const panelId = useId();
const getButtonProps = useCallback(
(props: React.ComponentProps<"button"> = {}) => ({
...props,
"aria-expanded": isOpen,
"aria-controls": panelId,
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
props.onClick?.(e); // caller's handler runs first
toggle();
},
}),
[isOpen, panelId, toggle],
);
const getPanelProps = useCallback(
(props: React.ComponentProps<"div"> = {}) => ({
...props,
id: panelId,
hidden: !isOpen,
}),
[isOpen, panelId],
);
return { isOpen, toggle, getButtonProps, getPanelProps };
}The props.onClick?.(e) call before toggle() is the detail that separates a real prop-getter from a toy: the caller can pass their own onClick and it still fires. That’s how a headless hook stays composable instead of hijacking the element.
The caller spreads the getters onto its own markup — and the markup is completely free. This is the payoff over render props: no wrapper component sits between the hook and your JSX, so there’s no nesting and no layout the hook forces on you. The same hook drives a button-and-panel, a <details>, or a fully custom design — only the spread targets change.
function FaqItem({ q, a }: { q: string; a: string }) {
const { getButtonProps, getPanelProps } = useDisclosure();
return (
<section>
<button {...getButtonProps({ className: "faq-trigger" })}>{q}</button>
<div {...getPanelProps({ className: "faq-panel" })}>{a}</div>
</section>
);
}Notice getButtonProps({ className: "faq-trigger" }) — the caller’s className flows straight through because the getter spreads ...props first. The accessibility wiring (aria-expanded, aria-controls, id, hidden) is handled by the hook; the styling and structure are the caller’s. Behaviour and markup, cleanly split.
Failure mode one: a hook that returns JSX is not a headless hook — it’s a component wearing a use prefix, and it throws away every advantage. The instant your hook returns <div>…</div>, the caller can no longer choose the element, the styling, or the structure; you’re back to a wrapper with none of a component’s clarity. If you want to return UI, write a component. A hook returns data and behaviour — values, handlers, getters — never elements.
// WRONG: this is a component pretending to be a hook
function useDisclosure() {
const [isOpen, setOpen] = useState(false);
return <button onClick={() => setOpen((v) => !v)}>{isOpen ? "Hide" : "Show"}</button>;
// caller can't restyle, can't pick the element, can't compose — headless freedom is gone
}
// RIGHT: return behaviour; the caller renders
function useDisclosure() {
const [isOpen, setOpen] = useState(false);
return { isOpen, toggle: () => setOpen((v) => !v) };
}If you find yourself reaching for JSX inside a hook, that’s the signal you wanted a component all along.
From render-prop wrapper to headless hook — same behaviour, the nesting gone. A <Toggle> exposes on/off state via a render prop. Two stacked behaviours already show the pyramid:
// before: render props — a wrapper per behaviour, markup buried at the bottom
function Toggle({ children }: { children: (s: { on: boolean; toggle: () => void }) => React.ReactNode }) {
const [on, setOn] = useState(false);
return <>{children({ on, toggle: () => setOn((v) => !v) })}</>;
}
<Toggle>
{(menu) => (
<Toggle>
{(tooltip) => (
<button onClick={menu.toggle} aria-expanded={menu.on}>
Menu {tooltip.on ? "(?)" : ""}
</button>
)}
</Toggle>
)}
</Toggle>Two <Toggle> wrappers, and the real button is two closures deep. Switch to the headless hook and both wrappers vanish:
// after: headless hook — flat, no wrappers, prop-getter does the ARIA wiring
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
const getToggleProps = useCallback(
(p: React.ComponentProps<"button"> = {}) => ({
...p,
"aria-pressed": on,
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
p.onClick?.(e);
toggle();
},
}),
[on, toggle],
);
return { on, toggle, getToggleProps };
}
function MenuButton() {
const menu = useToggle();
const tooltip = useToggle();
return (
<button {...menu.getToggleProps({ className: "menu" })}>
Menu {tooltip.on ? "(?)" : ""}
</button>
);
}Two behaviours are now two flat useToggle() calls, the markup is one readable line, and aria-pressed is wired by the getter. That’s the render-props-to-hooks migration in one screen: identical separation of behaviour and markup, none of the nesting tax.
▸Why this works
Why do prop-getters return a function instead of just an object of props? Because the getter has to merge the caller’s props with the hook’s. If getButtonProps were a fixed object, a caller adding their own onClick or className would have to manually merge it, and they’d inevitably clobber the hook’s onClick (losing the toggle) or vice versa. The function form lets the hook control the merge — call the caller’s handler, then run its own, and spread the caller’s other props through. This is exactly how mature headless libraries (Downshift, React Aria, Radix primitives) shape their APIs, and it’s why “prop-getter” became the standard name for the pattern.
▸Common mistake
The opposite failure of returning JSX is over-extraction: pulling a “headless hook” out when there’s exactly one caller and the logic is three lines. A useDisclosure shared by a dialog, a dropdown, and an accordion earns its file. A useDisclosure used once, in one component, with no second consumer in sight, is indirection for its own sake — now a reader has to jump to another file to learn that a boolean flips. The prop-getter machinery (merging handlers, useCallback deps, an aria-controls id) is real cost; pay it when the behaviour is genuinely reused or genuinely intricate, not reflexively. Inline useState(false) is the senior choice far more often than the catalogue of headless hooks suggests.
A colleague writes useDropdown() that internally manages open state and returns a fully-styled <ul> menu element ready to drop in. Judged as a headless hook, what's the core problem?
A custom hook is the most ergonomic headless pattern: useX() returns state, handlers, and prop-getters — functions like getButtonProps/getPanelProps that bundle the wiring (onClick, aria-expanded, id) and compose the caller’s own props — and the component spreads them onto whatever markup it wants. That’s why hooks-as-headless is the successor to render props for logic reuse: same separation of behaviour from markup, but no wrapper component, no nesting pyramid, and no constraint on your JSX. Make getters return functions so the hook controls the merge of caller and hook props. Then watch both failure modes: a hook that returns JSX is a component in disguise and throws away every benefit (return values, not elements); and a single-caller “headless hook” is over-extraction — inline useState reads better. Match the pattern to the pressure: reuse or intricate behaviour earns the hook; a one-off boolean does not.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.