Learn

/

Render Props

Render Props

5 patterns

When to use render functions vs ReactNode slots, and how to name headless component APIs.

Render function vs ReactNode slot
medium
Avoid
interface TooltipProps {
  children: React.ReactNode;
  content: React.ReactNode;
}

// Usage:
// <Tooltip content={<span>{data.label}</span>}>
//   <Button />
// </Tooltip>

Prefer
interface TooltipProps {
  children: React.ReactNode;
  /** Receives the anchor rect for positioning. */
  renderContent: (anchorRect: DOMRect) => React.ReactNode;
}

// Usage:
// <Tooltip renderContent={(rect) => (
//   <Popover style={{ top: rect.bottom }}>
//     {data.label}
//   </Popover>
// )}>
//   <Button />
// </Tooltip>

A render function (renderContent) gives the consumer access to runtime data — here, the anchor's position. A static ReactNode slot can't receive arguments. Use the render* prefix when the consumer needs data from the component to decide what to render.

React Docs: Alternatives to cloneElement
render* vs get* function props
easy
Avoid
interface AutocompleteProps<T> {
  options: T[];
  /** Returns the display label for an option. */
  renderLabel: (option: T) => string;
  /** Renders a custom option in the dropdown. */
  getOption: (option: T) => React.ReactNode;
}

Prefer
interface AutocompleteProps<T> {
  options: T[];
  /** Returns the display label for an option. */
  getOptionLabel: (option: T) => string;
  /** Renders a custom option in the dropdown. */
  renderOption: (option: T) => React.ReactNode;
}

render* functions return JSX (React.ReactNode) — they control what appears on screen. get* functions return data (strings, numbers, booleans) — they extract or compute values.

MUI follows this strictly: renderOption returns JSX for the dropdown, getOptionLabel returns a plain string for the input field. Swapping the prefixes reverses the reader's expectation.

MUI: Autocomplete API
Static slot vs unnecessary render function
easy
Avoid
interface EmptyStateProps {
  renderIcon: () => React.ReactNode;
  renderTitle: () => React.ReactNode;
  renderAction: () => React.ReactNode;
}

// Usage:
// <EmptyState
//   renderIcon={() => <SearchIcon />}
//   renderTitle={() => "No results found"}
//   renderAction={() => <Button>Reset</Button>}
// />

Prefer
interface EmptyStateProps {
  icon?: React.ReactNode;
  title: React.ReactNode;
  action?: React.ReactNode;
}

// Usage:
// <EmptyState
//   icon={<SearchIcon />}
//   title="No results found"
//   action={<Button>Reset</Button>}
// />

Render functions are only needed when the component passes data back to the consumer. Here, EmptyState doesn't provide any data — it just displays content. Plain ReactNode slots are simpler: icon={<SearchIcon />} vs renderIcon={() => <SearchIcon />}.

React Docs: Passing JSX as children
Error boundary fallback
medium
Avoid
interface ErrorBoundaryProps {
  children: React.ReactNode;
  fallback: React.ReactNode;
}

// Usage:
// <ErrorBoundary
//   fallback={<p>Something went wrong</p>}
// >
//   <App />
// </ErrorBoundary>

Prefer
interface ErrorBoundaryProps {
  children: React.ReactNode;
  renderFallback: (
    error: Error,
    reset: () => void,
  ) => React.ReactNode;
}

// Usage:
// <ErrorBoundary renderFallback={(error, reset) => (
//   <div>
//     <p>{error.message}</p>
//     <button onClick={reset}>Try again</button>
//   </div>
// )}>
//   <App />
// </ErrorBoundary>

A render function gives the fallback access to the actual Error and a reset function. A static ReactNode can't show the error message or offer a retry button. Use render* when the component has runtime data the consumer needs.

React Docs: Error Boundaries
Headless component with state callback
hard
Avoid
interface ToggleProps {
  isOn: boolean;
  onToggle: () => void;
  onLabel?: string;
  offLabel?: string;
  size?: 'sm' | 'md' | 'lg';
  color?: string;
  children?: React.ReactNode;
}

Prefer
interface ToggleState {
  isOn: boolean;
  toggle: () => void;
}

interface ToggleProps {
  isOn: boolean;
  onToggle: () => void;
  /**
   * Full control over rendering.
   * Receives the toggle state.
   */
  children: (state: ToggleState) => React.ReactNode;
}

A headless component exposes state and behavior through a render callback, leaving all visual decisions to the consumer. Instead of adding size, color, onLabel, and offLabel props to handle every use case, the consumer gets full control.

This is the pattern behind libraries like Downshift and React Aria.

React Docs: Alternatives to cloneElement