Headless UI Pattern — logic without markup

[check: …]
📖 guide (.md) ← react deep dive
📖 Pair this live React playground with the companion guide (.md) — this page is the rendered ground truth. ↗ Builds on use_reducer.html: the state reducer pattern IS useReducer turned inside-out so the consumer decides the next state.

what is "headless"? logic vs rendering

A headless component owns the plumbing — state, keyboard handling, ARIA wiring, focus management — but never renders a single <div>. The consumer owns the DOM. Two halves connect them: a state reducer (override any transition) and prop getters (spread the right props onto the right element). Invented by Kent C. Dodds, now powering react-table, downshift, Radix UI, Headless UI.

halfwho owns itwhat it gives the consumer
state reducer consumer passes a custom (state, action) → newState control over EVERY state transition — intercept, block, or rewrite changes
prop getters component exposes getXxxProps(extra) helpers spread the right a11y + event props without knowing internals
render consumer writes the JSX, picks tags & classes 100% styling freedom — Tailwind, CSS-in-JS, plain CSS, anything
plumbing component keeps it (a11y, keyboard, focus, IDs) correctness the consumer doesn't have to think about

1 · the headless useToggle hook (edit me)

2 · Babel compiles JSX → element tree

The JSX spread {'{...getToggleButtonProps({})}'} becomes a _extends(...) call merging props into React.createElement. Clicking dispatches {'{type:"TOGGLE", changes:{isOpen:...}}'} — the consumer reducer decides whether those changes survive.

// (hit "compile & render" to see Babel's output)

3 · live React (the pattern, proven)

The gold-check clicks the default toggle twice (closed → open → closed) and asserts the state text after each click. That proves the prop-getter spread pipeline and the default state reducer work through real DOM events. Try the bottom button yourself: open it 3 times and watch the custom reducer veto the close.

state-1: · gold: click toggle-btn → expect closed→open→closed

prop getter convention

intentpatternwhy
render the trigger <button {...getToggleButtonProps()}> gets aria-expanded, onClick, type=button, role in one spread
render the listbox/menu <ul {...getMenuProps()}> gets role=listbox, aria-labelledby, keyboard handler
render an option <li {...getItemProps({item, index})}> gets role=option, aria-selected, id, onClick, onMouseDown
override a prop getXxxProps({ className: 'mine', onClick: fn }) extra props win via Object.assign — consumer styling/behavior composes
veto a transition if (...) return state; in custom reducer return current state to ignore proposed changes
rewrite a transition return { ...action.changes, extra: true } return a modified changes object — full control of next state