Skip to content

Render props and headless components

Every component so far has had a face: it renders something you can see. A headless component has no styled UI of its own. It exists purely to provide behavior, and it renders whatever children you hand it. That sounds abstract until you notice how many widgets are the same behavior wearing different clothes: a favorite star, a dropdown menu, a dark-mode switch, and a "show more" section are all a boolean and a toggle function. A headless Toggle component captures that logic once, and each widget becomes markup composed around it.

This chapter follows the course in building that Toggle as a compound component family coordinated through context, then hits the wall that leads to the chapter's second idea: render props, the pattern for exposing a component's internal state to whoever renders it.

A headless Toggle family

Toggle owns a boolean and provides it, along with a toggle function, through a scoped ToggleContext provider. The rest of the family consumes that context, and each piece hangs off Toggle as a property (Toggle.Button = ToggleButton), the dot syntax from compound components:

jsx
<Toggle>
  <Toggle.Button>
    <Star />
  </Toggle.Button>
  <Toggle.On>The toggle is on</Toggle.On>
  <Toggle.Off>The toggle is off</Toggle.Off>
</Toggle>
  • Toggle.Button renders its children in a <button type="button"> with an onClick that flips the state. The children don't need their own click handlers; a click on anything inside bubbles up to the wrapper, which is event bubbling doing the work, by way of React's synthetic event system rather than a listener on the button itself. A clickable <div> would behave the same for mouse users while losing keyboard activation and the screen-reader announcement, as accessibility covers.
  • Toggle.On renders its children when the state is true, and null otherwise. Toggle.Off does the reverse.

The star icons, the menu markup, the labels, all come from the caller. That's what makes the component reusable across widgets that look nothing alike: the course drops the same Toggle under both the star and the menu, and the menu's own pieces wrap Toggle's pieces internally so the person using <Menu> never sees the machinery.

Letting the outside listen

A real star button doesn't only repaint; it tells a server someone clicked it. So the headless component needs an event-listener prop of its own, the same shape as onClick on your own components from the children and composition chapter. Toggle accepts an onToggle function and runs it from an effect whenever the state changes:

jsx
function Toggle({ children, onToggle = () => {} }) {
  const [on, setOn] = useState(false)
  const toggle = () => setOn(prevOn => !prevOn)
  const firstRender = useRef(true)

  useEffect(() => {
    if (firstRender.current) {
      firstRender.current = false
    } else {
      onToggle()
    }
  }, [on])

  // the provider and the children render below
}

Two small pieces of craft are hiding in there. The ref guards against the effect's first run: effects fire after the initial render too, and announcing a "toggle" that nobody performed is a bug, so the ref tracks whether this is still the first render without triggering re-renders the way state would. And the = () => {} default is a noop function, so a caller who doesn't care about the event doesn't crash the component when it calls onToggle() anyway.

The dependency array holds on alone. Adding onToggle, which is what exhaustive-deps asks for, would re-run the effect every time the caller passes a fresh inline function, so this is one of the spots where the lint rule and the intent disagree. One wrinkle in the guard also shows up in development. StrictMode runs each effect twice on mount, which spends the ref on the first run and lets the callback fire on the second. Production builds do not do this, and the durable fix is to derive the behavior from the change itself, comparing on against the previous value, rather than from whether this is the first render.

Render props: handing the inside out

Now the pattern hits a wall. Style a box with a CSS transition on its background color, render the filled version inside Toggle.On and the unfilled one inside Toggle.Off, and the transition never plays. The reason: Toggle.On and Toggle.Off mount and unmount their children. React isn't changing a class on one element; it's removing an element and inserting a different one, and CSS can't transition an element that stopped existing. What the caller needs is the on state itself, so one persistent element can vary its own class name. The state is trapped inside Toggle.

The escape hatch is a familiar move from JavaScript: callbacks invert control. When you call addEventListener("click", callback), you supply the function, but the browser calls it and the browser decides what it receives (the event object). A component can offer the same deal. Pass it a function, and the component will call that function with its internal state and render whatever the function returns:

jsx
function ToggleDisplay({ children }) {
  const { on } = useContext(ToggleContext)
  return children(on)
}

Toggle.Display = ToggleDisplay
jsx
<Toggle.Display>
  {on => <div className={`box ${on ? 'filled' : ''}`} />}
</Toggle.Display>

The children of Toggle.Display aren't elements this time; they're a function. Toggle.Display calls it, passes in on, and returns the JSX it gets back. Now the div is one element that survives every toggle, only its class changes, and the transition plays. This is the render props pattern: a prop whose value is a function that the component calls to know what to render. Some APIs used a literal prop named render, which is where the pattern's name comes from; passing the function as children is the form that stuck.

Render props solve state sharing between a component and its caller; custom hooks solve state sharing between plain functions, and after hooks arrived the ecosystem moved most render-prop APIs over. A useToggle() hook hands back on and toggle with no extra component, no nesting in the tree, and no function-as-children indirection, which is why libraries that once shipped <Downshift>-style render props now ship useSelect-style hooks.

Render props still earn their place where the provider needs to be a component: when the exposed value is tied to a specific rendered element (measuring, positioning), or when a library wants to gate what renders inside a boundary component, one that catches a render error below it or holds a loading state and decides what shows in its place. Recognize the pattern on sight, reach for it when the component boundary itself matters, and prefer a hook when it doesn't.

One structural note on Toggle.Display: calling children(on) during render means Toggle.Display re-invokes that function and re-renders its return value on every toggle of on. That's the point, and it's also the cost; a render prop is a subscription, and everything inside the function re-renders with the state it subscribes to. Keep the function's body small and push heavy subtrees outside it when they don't depend on the value.

JunoBehavior without a face A headless component is one that does something without showing anything of its own: a Toggle that keeps track of on and off, while you supply what it wraps. Its helpers render your content when the state is on or off.

And when you need the actual true-or-false value on your side, you pass the component a function as its child; the component calls your function with the value, and whatever your function returns is what appears on the page.

JunoBehavior without a face Build headless components as a compound family over a scoped context: a parent that owns state, a Button piece that flips it via a wrapping click handler, On and Off pieces that conditionally render children. Expose changes with an event prop like onToggle, defaulted to a noop and fired from an effect with a first-render ref guard.

When a caller needs the raw state, give them a Display piece that calls children as a function with the state, which is the render props pattern.

JunoBehavior without a face Headless components separate behavior from markup so one piece of logic serves visually unrelated widgets; conditional-render pieces like On and Off remount their children, so anything needing continuity, CSS transitions included, requires exposing state instead, via a render prop.

Treat render props as inversion of control with JSX and as a subscription with re-render cost, and prefer a custom hook for the same job unless the component boundary itself carries meaning.

Next up: Custom hooks in practice, where the same logic moves into a function and the component disappears.