Skip to content

Slot and Render Prop Patterns

advanced15 min read

Three Ways to Inject Rendering

React gives you three fundamental patterns for letting consumers control how a component renders its internals:

  1. Render Props — pass a function that receives data and returns JSX
  2. Slots — pass named JSX elements to specific insertion points
  3. 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.

Mental Model

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).

Quiz
What problem did render props originally solve before React hooks existed?

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>
Common Trap

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.

Quiz
When should you prefer a custom hook over a render prop in modern React?

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:

PatternHow It WorksProsCons
cloneElementClone child and inject propsTransparent to consumerFragile — breaks with fragments, conditionals, wrappers
Render PropsCall function with data, receive JSXExplicit data flow, type-safeNesting hell with multiple render props
ContextProvide data, consume anywhere in treeWorks at any depth, no prop threadingRe-renders all consumers on change
Slots (named props)Accept ReactNode at named positionsSimple, type-safe, declarativeCannot 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>
  );
}
Common Trap

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.

Quiz
Why is cloneElement considered problematic in modern React?

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 CaseBest PatternWhy
Fixed layout with customizable regionsSlots (named props)Each region has a clear name and position
Multi-part interactive component (Tabs, Accordion)Compound ComponentsParts share state implicitly through context
Consumer needs internal state to customize renderingRender PropsState flows explicitly through function params
Simple content injectionchildren propNo need for named slots or state passing
Dynamic list items with custom renderingRender Props or children-as-functionEach item needs the list's state (active, selected)
Quiz
A Dashboard component has a header, a grid of widgets, and a footer. Each widget needs access to a shared timeRange filter state. What pattern should you use?
What developers doWhat 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
Interview Question

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.