Slot and Render Prop Patterns
Three Ways to Inject Rendering
React gives you three fundamental patterns for letting consumers control how a component renders its internals:
- Render Props — pass a function that receives data and returns JSX
- Slots — pass named JSX elements to specific insertion points
- Children-as-function — a special case of render props using
children
Each has trade-offs. Knowing when to use which is what separates senior engineers from the rest.
Think of these patterns like a restaurant analogy. Render props are like a chef who asks you what to cook with the ingredients they have — "I've got chicken, rice, and peppers. What do you want me to make?" Slots are like a bento box — you fill each compartment (header, body, footer) with whatever you want. Children-as-function is like a chef who lets you step into the kitchen with their tools.
Render Props
The render prop pattern passes a function as a prop. The component calls that function with its internal state, and the function returns JSX.
interface MouseTrackerProps {
render: (position: { x: number; y: number }) => ReactNode;
}
function MouseTracker({ render }: MouseTrackerProps) {
const [position, setPosition] = useState({ x: 0, y: 0 });
return (
<div
className="h-full w-full"
onMouseMove={(e) => setPosition({ x: e.clientX, y: e.clientY })}
>
{render(position)}
</div>
);
}
// Usage — consumer decides what to render with the mouse position
<MouseTracker
render={({ x, y }) => (
<div className="pointer-events-none absolute" style={{ left: x, top: y }}>
<Cursor />
</div>
)}
/>
The component owns the behavior (tracking mouse position). The consumer owns the rendering (what to show at that position).
Children-as-Function
A common variant of render props: instead of a named prop, use children as the function.
interface FetchDataProps<T> {
url: string;
children: (state: { data: T | null; loading: boolean; error: Error | null }) => ReactNode;
}
function FetchData<T>({ url, children }: FetchDataProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
fetch(url)
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return <>{children({ data, loading, error })}</>;
}
// Usage
<FetchData<Course[]> url="/api/courses">
{({ data, loading, error }) => {
if (loading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
return <CourseGrid courses={data!} />;
}}
</FetchData>
Children-as-function creates a new function on every render. This means the component re-renders its children output on every parent render, even if the data has not changed. In the hooks era, this is rarely the best choice. A custom hook (useFetch) returns the same data and avoids the re-render problem entirely.
When Render Props Still Make Sense
Hooks replaced most render prop use cases. But render props still shine in specific situations:
1. When the consumer needs rendering control AND the component needs DOM nesting:
// The component needs to wrap the consumer's JSX in a specific DOM structure
function Tooltip({ content, children }: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);
const triggerRef = useRef<HTMLElement>(null);
return (
<>
<span
ref={triggerRef}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
>
{children}
</span>
{isVisible && (
<Portal>
<TooltipContent anchorRef={triggerRef}>{content}</TooltipContent>
</Portal>
)}
</>
);
}
A hook cannot provide DOM wrapping. The Tooltip needs to render a Portal with positioning logic. This requires a component, not a hook.
2. When you need to pass state to a specific rendering slot:
<Listbox value={selected} onChange={setSelected}>
{({ open }) => (
<>
<ListboxButton className={open ? "border-blue-500" : "border-gray-300"}>
{selected.name}
</ListboxButton>
{open && (
<ListboxOptions>
{options.map((option) => (
<ListboxOption key={option.id} value={option}>
{({ active, selected: isSelected }) => (
<span className={cn(active && "bg-blue-100", isSelected && "font-bold")}>
{option.name}
</span>
)}
</ListboxOption>
))}
</ListboxOptions>
)}
</>
)}
</Listbox>
Headless UI uses this pattern. Each compound part exposes its internal state through a render function, letting consumers style based on active, selected, disabled, etc.
The Slot Pattern
Slots let consumers inject JSX into named positions within a component. Think of them as labeled holes in a template that the consumer fills.
Named Props as Slots
The simplest slot implementation: named ReactNode props.
interface PageLayoutProps {
header: ReactNode;
sidebar?: ReactNode;
children: ReactNode;
footer?: ReactNode;
}
function PageLayout({ header, sidebar, children, footer }: PageLayoutProps) {
return (
<div className="grid min-h-screen grid-rows-[auto_1fr_auto]">
<header className="border-b">{header}</header>
<div className="grid grid-cols-[280px_1fr]">
{sidebar && <aside className="border-r p-4">{sidebar}</aside>}
<main className="p-6">{children}</main>
</div>
{footer && <footer className="border-t p-4">{footer}</footer>}
</div>
);
}
// Usage
<PageLayout
header={<NavBar />}
sidebar={<SidebarNav items={navItems} />}
footer={<Footer />}
>
<CourseContent />
</PageLayout>
This is the most common slot pattern in React. Clean, type-safe, easy to understand.
Slots with Data (Render Function Slots)
Sometimes a slot needs data from the component. Combine slots with render functions:
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
renderRow?: (item: T, index: number) => ReactNode;
renderEmpty?: () => ReactNode;
header?: ReactNode;
footer?: (meta: { total: number; showing: number }) => ReactNode;
}
function DataTable<T>({ data, columns, renderRow, renderEmpty, header, footer }: DataTableProps<T>) {
return (
<div>
{header}
<table>
<thead>
<tr>
{columns.map((col) => (
<th key={col.key}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0
? renderEmpty?.()
: data.map((item, i) =>
renderRow ? renderRow(item, i) : <DefaultRow key={i} item={item} columns={columns} />,
)}
</tbody>
</table>
{footer?.({ total: data.length, showing: data.length })}
</div>
);
}
The footer slot receives metadata from the table. The renderRow slot receives each data item. The header slot is a plain ReactNode — it does not need table data.
cloneElement vs Render Props vs Context
Three ways to pass data from parent to children. Let us compare them directly:
| Pattern | How It Works | Pros | Cons |
|---|---|---|---|
| cloneElement | Clone child and inject props | Transparent to consumer | Fragile — breaks with fragments, conditionals, wrappers |
| Render Props | Call function with data, receive JSX | Explicit data flow, type-safe | Nesting hell with multiple render props |
| Context | Provide data, consume anywhere in tree | Works at any depth, no prop threading | Re-renders all consumers on change |
| Slots (named props) | Accept ReactNode at named positions | Simple, type-safe, declarative | Cannot pass data back to slot content |
// cloneElement — AVOID in new code
function TabList({ children, activeIndex }: TabListProps) {
return (
<div role="tablist">
{Children.map(children, (child, index) =>
isValidElement(child)
? cloneElement(child, { isActive: index === activeIndex })
: child,
)}
</div>
);
}
cloneElement is considered legacy in modern React. It breaks when children are wrapped in fragments, conditional expressions, or higher-order components. It also makes the API opaque — the consumer does not see that isActive is being injected. Use Context (for compound components) or render props (for explicit data passing) instead.
Building a Slot-Based Layout System
For larger applications, you can formalize the slot pattern into a reusable layout system:
type SlotName = "header" | "sidebar" | "content" | "footer" | "toolbar";
interface LayoutSlots {
[key: string]: ReactNode;
}
interface LayoutProps {
slots: Partial<Record<SlotName, ReactNode>>;
variant?: "full" | "sidebar" | "centered";
}
function AppLayout({ slots, variant = "sidebar" }: LayoutProps) {
const layouts: Record<string, string> = {
full: "grid-cols-1",
sidebar: "grid-cols-[280px_1fr]",
centered: "grid-cols-1 max-w-3xl mx-auto",
};
return (
<div className="grid min-h-screen grid-rows-[auto_1fr_auto]">
{slots.toolbar && <div className="border-b bg-zinc-50">{slots.toolbar}</div>}
{slots.header && <header className="border-b px-6 py-4">{slots.header}</header>}
<div className={cn("grid", layouts[variant])}>
{slots.sidebar && <aside className="border-r">{slots.sidebar}</aside>}
<main className="p-6">{slots.content}</main>
</div>
{slots.footer && <footer className="border-t px-6 py-3">{slots.footer}</footer>}
</div>
);
}
// Usage
<AppLayout
variant="sidebar"
slots={{
header: <TopNav />,
sidebar: <CourseNav modules={modules} />,
content: <LessonContent />,
footer: <LessonPagination />,
}}
/>
The slot names are typed. The layout variant determines the grid structure. Unused slots are automatically omitted. This scales to complex page layouts without prop explosion.
Comparison: Slots vs Compound Components vs Render Props
When should you reach for each pattern?
| Use Case | Best Pattern | Why |
|---|---|---|
| Fixed layout with customizable regions | Slots (named props) | Each region has a clear name and position |
| Multi-part interactive component (Tabs, Accordion) | Compound Components | Parts share state implicitly through context |
| Consumer needs internal state to customize rendering | Render Props | State flows explicitly through function params |
| Simple content injection | children prop | No need for named slots or state passing |
| Dynamic list items with custom rendering | Render Props or children-as-function | Each item needs the list's state (active, selected) |
| What developers do | What they should do |
|---|---|
| Nesting multiple render props, creating callback hell Three nested render props is unreadable. Hooks compose linearly: const mouse = useMouse(); const scroll = useScroll(); No nesting. | Extract shared logic into custom hooks, use render props only where DOM wrapping is needed |
| Using cloneElement to inject props into children cloneElement is fragile and creates implicit APIs. Context and render props make the data flow visible in the code. | Use Context for implicit state sharing, or render props for explicit data passing |
| Creating slot props that accept both ReactNode and render functions inconsistently Mixing ReactNode slots and function slots in the same component creates an inconsistent API. Consumers have to check the type docs for each slot. | Pick one pattern per component: either all slots are ReactNode or all slots that need data are functions |
You are building a data table component for a design system. It needs: sortable columns, row selection, expandable rows, custom cell rendering, a toolbar, and pagination. Which pattern(s) would you use? Would you combine compound components with render props? How would you handle the custom cell rendering for each column? Show the API you would expose to consumers.