Polymorphic hosts fix which element you render. Composition fixes how behavior and UI share state without a prop bag that grows every sprint.
Render props were the old escape hatch from HOCs. Hooks are the modern default. I still do not delete every render prop on sight: I migrate when the call-site clarity and test surface improve, because hooks actually improve clarity and test surface.
What went wrong with prop sprawl
tsx// Illustrative, the bag that "just one more boolean" built <DataTable data={rows} sortable sortKey={key} sortDir={dir} onSortChange={setSort} selectable selectedIds={selected} onSelectedIdsChange={setSelected} renderRow={...} renderEmpty={...} renderToolbar={...} />
Every concern became a prop. Consumers learn the table by reading a type with forty optionals. Variants and polymorphism cannot save an API that mixed state ownership, layout slots, and domain callbacks in one component.
Render props: what they were good at
A render prop (or children-as-function) inverts control: the parent owns behavior; the child owns presentation.
tsx// Classic, illustrative function Pointer({ children, }: { children: (p: { x: number; y: number }) => ReactNode; }) { const [p, setP] = useState({ x: 0, y: 0 }); return ( <div onPointerMove={(e) => setP({ x: e.clientX, y: e.clientY })}> {children(p)} </div> ); } // usage <Pointer> {({ x, y }) => ( <Cursor x={x} y={y} /> )} </Pointer>;
Strengths: explicit dependency on provided state; easy to swap UI; no wrapper hell from HOCs.
Costs: nesting ("wrapper hell" of a different shape), awkward TypeScript on deeply nested children functions, harder to share the same state across siblings without lifting again.
Hooks: the same inversion, flatter trees
tsx// packages/ui/src/use-pointer.ts, illustrative export function usePointer(ref: RefObject<HTMLElement | null>) { const [p, setP] = useState({ x: 0, y: 0 }); useEffect(() => { const el = ref.current; if (!el) return; const onMove = (e: PointerEvent) => setP({ x: e.clientX, y: e.clientY }); el.addEventListener('pointermove', onMove); return () => el.removeEventListener('pointermove', onMove); }, [ref]); return p; }
Hooks pull behavior into a named unit you can test without mounting three layers of render props. Call sites read top-down: get state, then render.
Migration judgment I use:
- Is the render prop only plumbing state that a hook could return? → migrate.
- Does the parent need to inject UI into a specific DOM slot the child owns (portal target, table cell)? → keep a slot / render prop / compound child.
- Are there three nested render props? → almost always hooks + composition.
Compound components as the other escape hatch
When the problem is structure, not reusable state, prefer compound parts over either render props or a mega-prop API:
tsx// Illustrative compound, ownership stays in context <Tabs defaultValue="overview"> <Tabs.List> <Tabs.Tab value="overview">Overview</Tabs.Tab> <Tabs.Tab value="activity">Activity</Tabs.Tab> </Tabs.List> <Tabs.Panel value="overview">...</Tabs.Panel> <Tabs.Panel value="activity">...</Tabs.Panel> </Tabs>
This continues the primitives-to-domain pattern altitude: choreography without
dumping every knob on one root. Pair with CVA on the parts, not one Tabs with
tabClassName / panelClassName / listClassName forever.
When a render prop still pays off
Keep (or introduce) a render prop when:
- The library must render inside a child layout it does not control
- You need per-item injection (
renderItem) with stable list virtualization - Migration cost exceeds the clarity win on a stable, rarely touched API
tsx// Still fine, item injection for a virtualized list <VirtualList items={items} renderItem={(item, row) => ( <Row item={item} style={row.style} /> )} />
A hook cannot paint into a row the list owns. Slots and render props remain honest tools.
Migration playbook I actually use
I do not rewrite a stable render-prop API in one PR for style. Sequence:
- Extract the state into a hook used inside the existing render-prop component (behavior moves; call sites unchanged).
- Export the hook for new call sites; mark the render-prop wrapper deprecated in the package changelog.
- Delete the wrapper when greps are clean, or keep a thin adapter if a public package still promises it.
tsx// Step 1-2 sketch, same behavior, two doors function Pointer({ children }: { children: (p: Point) => ReactNode }) { const p = usePointer(/* ... */); return children(p); } export { usePointer, Pointer };
This keeps primitives-to-domain package-boundary discipline: deprecations are versioned promises, not drive-by rewrites in app code.
A concrete migration shape
When I do replace a render-prop parent, I keep the steps boring:
- Extract the state machine into
useXwith the same public fields the render prop used to pass. - Leave a thin compatibility wrapper that calls the hook and invokes
children(state)so call sites migrate one at a time. - Delete the wrapper when the last consumer is flat.
tsx// illustrative compatibility shim during migration function Pointer({ children, }: { children: (p: { x: number; y: number }) => ReactNode; }) { const ref = useRef<HTMLDivElement>(null); const p = usePointer(ref); return <div ref={ref}>{children(p)}</div>; }
Do not big-bang delete the render prop in the same PR that invents three new hooks. Continuity for consumers is part of the craft.
Failure modes
Hooks that reimplement context badly. Five hooks all reading the same module singleton, you rebuilt invisible coupling. Prefer React context or explicit arguments.
Render props renamed to children functions everywhere. Same nesting, new
fashion.
"Everything is a hook" including components that only return JSX. If there is no stateful or effectful behavior, it is a function component, leave it.
Ignoring forms/toasts lessons. Controlled state and notification queues already taught ownership. Composition patterns should not reopen a second global store for UI slots.
Compound context as a dump. Forty fields on one context is prop sprawl with better PR optics, split providers by update rate.
Continuity
CVA, class merge, and polymorphic hosts hardened the primitive surface.
Composition is how behavior and structure compose without prop sprawl.
TypeScript generics on those reusable components add constraints that justify
the complexity instead of any escape hatches.
Takeaway
Hooks replace render props when state is the product; slots and render props stay when the child owns the hole you must fill. Composition beats another boolean either way.