Headless Components and Render Props

Headless UI is a deliberate split: behavior and semantics on one side, presentation on the other. The goal is reuse that does not fight your design system. If you ship a <DatePicker> with hard-coded padding, border radius, and iconography, every product team eventually forks it or wraps it with !important. If you ship state + ARIA + keyboard handling and let consumers supply markup, the same primitive can power a marketing site, a dense admin console, and a white-label tenant theme without copying logic.

React offers several ways to express “logic without markup.” Two durable options are custom hooks and render props (including “function as children”). In 2026, hooks usually win for ergonomics—but render props still matter in specific collaboration shapes.

The headless idea also pairs naturally with TypeScript-first public APIs: your hook’s return type is the contract. Consumers spread that object into JSX or destructure it; either way, the compiler prevents typos like disclosure.isOpened or missing close handlers on a modal that must trap focus until dismissed.

Behavior as a hook: disclosure

Consider disclosure: open, close, toggle, and a boolean flag. As a hook, the consumer owns the tree entirely.

interface UseDisclosureReturn {
  isOpen: boolean
  open: () => void
  close: () => void
  toggle: () => void
}

function useDisclosure(initial = false): UseDisclosureReturn {
  const [isOpen, setIsOpen] = useState(initial)
  return {
    isOpen,
    open: () => setIsOpen(true),
    close: () => setIsOpen(false),
    toggle: () => setIsOpen(prev => !prev),
  }
}

function MyDialog() {
  const disclosure = useDisclosure()
  return (
    <>
      <button onClick={disclosure.toggle}>Open</button>
      {disclosure.isOpen && (
        <Dialog onClose={disclosure.close}>Content</Dialog>
      )}
    </>
  )
}

This is headless in the purest sense: zero opinions about DOM nodes, only state transitions. TypeScript models the return type once; every caller gets autocomplete and refactors that stay honest.

When a render prop still earns its keep

Hooks create a new state instance per call site. That is what you want most of the time. Sometimes you need one logical instance shared across multiple JSX subtrees in a way that is awkward to thread with hooks alone—for example, coordinated UI where a header button and each row item should manipulate the same disclosure, or where a parent component must own the lifecycle of a subscription and expose slices of state to unrelated children.

A thin render-prop wrapper around the same hook keeps the implementation unified while forcing colocation of the shared state:

interface DisclosureRenderProps {
  children: (state: UseDisclosureReturn) => React.ReactNode
  initial?: boolean
}

function Disclosure({ children, initial = false }: DisclosureRenderProps) {
  const state = useDisclosure(initial)
  return <>{children(state)}</>
}

The decision rule for 2026 is practical:

  • Prefer hooks when the consumer controls the component tree and a single call site is enough.
  • Reach for render props when multiple branches need the same state instance without lifting a dozen callbacks through intermediate layers, or when you are building a library API that must work in class components and legacy code paths.

Neither approach replaces compound components; they solve different problems. Compound components model slot hierarchies. Headless hooks model portable behavior.

Example: one disclosure, two surfaces

Imagine a filter drawer: a compact “Filters” control in the toolbar must open the same panel as a “Refine” link in the results header. With two useDisclosure() calls, toggling one leaves the other ignorant. A render-prop shell keeps a single isOpen while still letting each surface render its own markup:

<Disclosure>
  {({ isOpen, open, close, toggle }) => (
    <>
      <ToolbarButton onClick={toggle} aria-expanded={isOpen} />
      <ResultsHeaderLink onClick={open} />
      {isOpen && <FilterDrawer onDismiss={close} />}
    </>
  )}
</Disclosure>

You could lift state to a parent instead; render props shine when the parent would otherwise become a prop kitchen threading openDrawer, closeDrawer, and isDrawerOpen through layers that should not care.

Production references: Radix UI and Headless UI

You do not have to invent low-level primitives from scratch. Radix UI provides unstyled, accessible building blocks—dialogs, dropdowns, scroll areas—with focus management and keyboard interactions handled internally. Consumers bring styling via CSS modules, Tailwind, or CSS-in-JS. The API often blends context-backed compound pieces with data attributes for styling hooks, which is a mature compromise between flexibility and structure.

Headless UI (from the Tailwind team) follows a similar philosophy: components that know how to behave, not how to look. It is a useful reference implementation when you are studying how to wire ARIA roles, focus traps, and transition-friendly open/close semantics without baking in visual design.

React Aria (Adobe) leans hook-first: behavior as hooks and utilities you attach to your elements, with documentation aimed at design-system authors. That model aligns closely with “headless” as TypeScript-friendly, testable logic.

When evaluating such libraries, look past marketing. Inspect whether they document focus behavior, how they handle portals and stacking context, and whether TypeScript types encode invalid combinations (for example, items without a matching list container). The best headless layers fail loudly in development.

Tradeoffs: flexibility versus guidance

Render-prop and hook-based APIs push complexity onto the caller. That is the point—but it means your team needs conventions: who owns labels and aria-labelledby, who ensures escape closes overlays, how you test keyboard paths. Headless libraries reduce that burden by centralizing accessibility; your job is to compose them consistently.

From a TypeScript perspective, prefer explicit return types on hooks (UseDisclosureReturn) so refactors do not silently widen or narrow shapes. For render props, type the children function argument narrowly—avoid any state bags that turn every consumer into untyped JavaScript with extra steps.

Testing and documentation

Headless logic is cheap to test: call the hook with renderHook, assert transitions, and simulate unmount to ensure listeners unsubscribe. When you expose a render-prop component, add one integration-style test that renders a minimal child function and asserts the state object matches what your docs promise—those contracts are what downstream design systems depend on.

Storybook stories should show both happy paths and edge cases: opening a dialog while another focus trap is active, moving focus after close, and restoring scroll on mobile. The headless layer is “invisible” in the UI, so without stories, regressions show up first in accessibility audits rather than in review.

Performance and render-prop ergonomics

Render props re-invoke the child function on every render of the parent wrapper. That is usually fine; the expensive work still belongs in memoized children or in hooks with stable references. If you notice unnecessary re-renders, split volatile state so only subscribers that need it re-render—similar to splitting context into state and dispatch. Hooks remain the first optimization: they avoid an extra wrapper component in the tree.

Summary

Headless patterns separate what happens from what it looks like. Hooks are the default tool in modern React for that separation. Render props remain a sharp tool when shared state must flow through a single component instance across multiple render branches. Radix UI, Headless UI, and React Aria demonstrate how far you can push the idea in production: deep accessibility, minimal styling opinions, and APIs that TypeScript teams can wrap in branded design systems without forking behavior.