Render Props — Share behavior between components by passing a function that returns JSX
Recipe
// A headless component that manages mouse position
function MouseTracker({ render }: {
render: (position: { x: number; y: number }) => React.ReactNode;
}) {
const [position, setPosition] = useState({ x: 0, y: 0 });
const handleMouseMove = (e: React.MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
};
return <div onMouseMove={handleMouseMove}>{render(position)}</div>;
}
// Usage
<MouseTracker
render={({ x, y }) => <p>Mouse is at ({x}, {y})</p>}
/>When to reach for this: When a component encapsulates reusable behavior or state logic and the consumer needs to control what gets rendered. Also useful for headless UI components.
Working Example
import { useState, useRef, useEffect, type ReactNode } from "react";
// Headless disclosure component using render prop
interface DisclosureRenderProps {
isOpen: boolean;
toggle: () => void;
open: () => void;
close: () => void;
triggerProps: {
onClick: () => void;
"aria-expanded": boolean;
"aria-controls": string;
};
contentProps: {
id: string;
role: "region";
hidden: boolean;
};
}
function Disclosure({
id,
defaultOpen = false,
children,
}: {
id: string;
defaultOpen?: boolean;
children: (props: DisclosureRenderProps) => ReactNode;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const contentId = `${id}-content`;
const api: DisclosureRenderProps = {
isOpen,
toggle: () => setIsOpen((prev) => !prev),
open: () => setIsOpen(true),
close: () => setIsOpen(false),
triggerProps: {
onClick: () => setIsOpen((prev) => !prev),
"aria-expanded": isOpen,
"aria-controls": contentId,
},
contentProps: {
id: contentId,
role: "region",
hidden: !isOpen,
},
};
return <>{children(api)}</>;
}
// Usage — full control over rendering
function FAQ({ items }: { items: { q: string; a: string }[] }) {
return (
<div className="space-y-2">
{items.map((item, i) => (
<Disclosure key={i} id={`faq-${i}`}>
{({ triggerProps, contentProps, isOpen }) => (
<div className="border rounded-lg">
<button
{...triggerProps}
className="w-full text-left p-4 font-medium flex justify-between"
>
{item.q}
<span>{isOpen ? "−" : "+"}</span>
</button>
<div {...contentProps} className="px-4 pb-4 text-gray-600">
{item.a}
</div>
</div>
)}
</Disclosure>
))}
</div>
);
}What this demonstrates:
- Function-as-children pattern (the most common render prop variant)
- Headless component — manages state and ARIA but renders nothing on its own
- Consumer gets full control over DOM structure and styling
- Accessibility props are provided by the headless component
Deep Dive
How It Works
- A render prop is any prop whose value is a function that returns
ReactNode. - The component calls this function during render, passing internal state or computed values as arguments.
- The
childrenvariant (children: (props) => ReactNode) is the most common form, sometimes called "function-as-children." - The pattern separates behavior (state management, event handling, accessibility) from presentation (DOM, styles).
- Headless UI libraries (Headless UI, Radix primitives, Downshift) are built on this concept.
Parameters & Return Values
| Parameter | Type | Purpose |
|---|---|---|
render or children | (state: T) => ReactNode | Function the consumer provides to control rendering |
| Internal state | Varies | Passed as argument to the render function |
| Event handlers | () => void etc. | Provided to the consumer for binding to their own elements |
| ARIA props | Spread objects | Pre-built accessibility attributes for consumer elements |
Variations
Named render prop — useful when you need multiple render slots:
function DataTable<T>({
data,
renderHeader,
renderRow,
renderEmpty,
}: {
data: T[];
renderHeader: () => ReactNode;
renderRow: (item: T, index: number) => ReactNode;
renderEmpty: () => ReactNode;
}) {
if (data.length === 0) return <>{renderEmpty()}</>;
return (
<table>
<thead>{renderHeader()}</thead>
<tbody>{data.map((item, i) => renderRow(item, i))}</tbody>
</table>
);
}Prop collection pattern — group related props into spreadable objects:
// Instead of individual props:
// onClick={toggle} aria-expanded={isOpen} aria-controls={id}
// Provide a collection:
// {...triggerProps}TypeScript Notes
- Always type the render function argument explicitly so consumers get autocomplete.
- Use generics for render props that work with varying data types:
children: (item: T) => ReactNode. - Export the render prop argument type so consumers can type their render functions externally if needed.
Gotchas
-
New function on every render — Inline render props create a new function each render, which can interfere with
React.memo. Fix: Extract the render function to a stable reference withuseCallbackif the parent is memoized. -
Wrapper hell — Nesting multiple render prop components creates deep indentation. Fix: Extract custom hooks for the behavior instead. Most render prop patterns can be converted to hooks.
-
Returning fragments without keys — When render props return lists, each item needs a key. Fix: Ensure the consumer returns keyed elements when rendering lists.
-
Breaking rules of hooks inside render props — You cannot call hooks inside the render function passed to a render prop. Fix: If hooks are needed, extract a separate component and pass data as props.
Alternatives
| Approach | Trade-off |
|---|---|
| Render props | Full rendering control; can lead to nesting |
| Custom hooks | Cleaner API; cannot encapsulate JSX structure |
| Composition (slots) | Simpler; slot content cannot access internal state |
| Higher-order components | Adds behavior transparently; harder to type correctly |
| Compound components | Multiple related elements share state implicitly |
FAQs
What is the difference between a render prop and function-as-children?
- A render prop is any prop whose value is a function that returns
ReactNode. - Function-as-children is a specific variant where
childrenis the render function:children: (props) => ReactNode. - Function-as-children is the most common form and avoids the extra prop name.
What is a headless component?
- A headless component manages state, event handling, and accessibility but renders no DOM of its own.
- The consumer controls all rendering through a render prop.
- Libraries like Headless UI, Radix, and Downshift are built on this concept.
How does the prop collection pattern simplify render props?
// Instead of passing individual props:
// onClick={toggle} aria-expanded={isOpen} aria-controls={id}
// Provide a spreadable object:
// {...triggerProps}- Group related props (event handlers, ARIA attributes) into a single object.
- Consumers spread the object onto their element, reducing boilerplate and preventing missed attributes.
When should you use multiple named render props instead of a single children render prop?
<DataTable
data={rows}
renderHeader={() => <tr><th>Name</th></tr>}
renderRow={(item) => <tr><td>{item.name}</td></tr>}
renderEmpty={() => <p>No data</p>}
/>- Use multiple named render props when the component has distinct rendering slots (header, row, empty state).
- A single
childrenrender prop works when there is only one rendering area.
Gotcha: Why can inline render props break React.memo?
- Inline render functions create a new function reference on every render.
- If the parent component is memoized, the new reference causes it to re-render regardless.
- Fix: extract the render function and stabilize it with
useCallbackif the parent is memoized.
Gotcha: Can you call hooks inside a render prop function?
- No. Hooks cannot be called inside the render function passed to a render prop.
- The function runs during the parent's render, not as its own component.
- Fix: extract a separate component that receives the data as props and calls hooks inside that component.
How do you type a generic render prop in TypeScript?
interface ListProps<T> {
items: T[];
children: (item: T, index: number) => ReactNode;
}
function List<T>({ items, children }: ListProps<T>) {
return <ul>{items.map((item, i) => <li key={i}>{children(item, i)}</li>)}</ul>;
}- Use a generic type parameter on both the component and the render function.
- TypeScript infers
Tfrom theitemsarray and enforces it in the render function argument.
Should you export the render prop argument type for consumers?
- Yes. Exporting the type (e.g.,
DisclosureRenderProps) lets consumers type their render functions externally. - This improves autocomplete and catches type errors when the render function is defined in a separate file.
Why are render props less common in modern React, and what replaced them?
- Custom hooks can encapsulate the same reusable behavior without nesting.
- Hooks provide a cleaner API and avoid the deep indentation ("wrapper hell") of nested render props.
- Render props are still useful when you need to encapsulate JSX structure, not just logic.
How do render props handle accessibility compared to composition?
- Render props can provide pre-built ARIA attribute objects (like
triggerPropsandcontentProps) for the consumer to spread. - The headless component owns the accessibility logic while the consumer owns the DOM structure.
- This is how libraries like Downshift and Headless UI deliver accessible widgets without dictating markup.
How do you avoid wrapper hell with multiple render prop components?
- Extract each render prop component's behavior into a custom hook.
- Use the hooks together in a single component instead of nesting render prop wrappers.
- Most render prop patterns have a direct hook equivalent.
Related
- Composition — Simpler alternative when slots don't need internal state
- Higher-Order Components — Alternative pattern for behavior reuse
- Compound Components — Multi-part API that shares state via context