React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

render-propsheadlessfunction-as-childreact-patterns

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 children variant (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

ParameterTypePurpose
render or children(state: T) => ReactNodeFunction the consumer provides to control rendering
Internal stateVariesPassed as argument to the render function
Event handlers() => void etc.Provided to the consumer for binding to their own elements
ARIA propsSpread objectsPre-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 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.

Alternatives

ApproachTrade-off
Render propsFull rendering control; can lead to nesting
Custom hooksCleaner API; cannot encapsulate JSX structure
Composition (slots)Simpler; slot content cannot access internal state
Higher-order componentsAdds behavior transparently; harder to type correctly
Compound componentsMultiple 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 children is 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 children render 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 useCallback if 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 T from the items array 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 triggerProps and contentProps) 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.