Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
// 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.
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:
ReactNode.children variant (children: (props) => ReactNode) is the most common form, sometimes called "function-as-children."| 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 |
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}children: (item: T) => ReactNode.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 with useCallback if 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.
| 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 |
ReactNode.children is the render function: children: (props) => ReactNode.// Instead of passing individual props:
// onClick={toggle} aria-expanded={isOpen} aria-controls={id}
// Provide a spreadable object:
// {...triggerProps}<DataTable
data={rows}
renderHeader={() => <tr><th>Name</th></tr>}
renderRow={(item) => <tr><td>{item.name}</td></tr>}
renderEmpty={() => <p>No data</p>}
/>children render prop works when there is only one rendering area.useCallback if the parent is memoized.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>;
}T from the items array and enforces it in the render function argument.DisclosureRenderProps) lets consumers type their render functions externally.triggerProps and contentProps) for the consumer to spread.Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥