Back to CourseAdvance4 min read
React /

Render Props

Share behaviour by passing a function that returns markup, and know the few cases where it still beats a hook.

Bishal Biswas

Bishal Biswas

Self Learner

Published on: May 26, 2026
Last updated: September 5, 2026
Advance Topic

The pattern

A component takes a function as a prop and calls it with data, letting the caller decide what to render.

function MousePosition({ render }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  useEffect(() => {
    function handleMove(event) {
      setPosition({ x: event.clientX, y: event.clientY });
    }
    window.addEventListener('mousemove', handleMove);
    return () => window.removeEventListener('mousemove', handleMove);
  }, []);
  return render(position);
}
function App() {
  return <MousePosition render={({ x, y }) => <p>Mouse at {x}, {y}</p>} />;
}

MousePosition owns the behaviour. The caller owns the markup.

Using children as the function is the more common form:

function MousePosition({ children }) {
  return children(position);
}
function App() {
  return (
    <MousePosition>
      {({ x, y }) => <p>Mouse at {x}, {y}</p>}
    </MousePosition>
  );
}

Why it existed

Before hooks, this and HOCs were the only ways to share stateful logic. Render props had one clear advantage over HOCs: you can see where the values come from.

export default withMouse(withWindow(withTheme(Component)));
<Mouse>
  {(mouse) => (
    <Window>
      {(window) => <Component mouse={mouse} window={window} />}
    </Window>
  )}
</Mouse>

No mystery props. But look at the shape. Three shared values means three levels of nesting, and this got the name callback hell for components.

Hooks made it unnecessary

function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  useEffect(() => {
    function handleMove(event) {
      setPosition({ x: event.clientX, y: event.clientY });
    }
    window.addEventListener('mousemove', handleMove);
    return () => window.removeEventListener('mousemove', handleMove);
  }, []);
  return position;
}
function App() {
  const { x, y } = useMousePosition();
  const size = useWindowSize();
  const { theme } = useTheme();
  return <p>Mouse at {x}, {y}</p>;
}

Flat, obvious, and no nesting no matter how many values you need. For sharing logic, hooks win outright.

Where it still fits

The pattern is not dead. It is right whenever the shared thing is markup structure, not just data. A hook cannot render anything around your content.

Layout components

function DataTable({ rows, columns, renderRow, renderEmpty }) {
  if (rows.length === 0) return renderEmpty?.() ?? <p>No data</p>;
  return (
    <table>
      <thead>
        <tr>{columns.map((c) => <th key={c.key}>{c.label}</th>)}</tr>
      </thead>
      <tbody>{rows.map((row) => renderRow(row))}</tbody>
    </table>
  );
}
<DataTable
  rows={users}
  columns={columns}
  renderRow={(user) => (
    <tr key={user.id}>
      <td>{user.name}</td>
      <td><Badge status={user.status} /></td>
    </tr>
  )}
  renderEmpty={() => <EmptyState message="No users yet" />}
/>

The table owns the structure and the empty handling. The caller owns each cell. A hook cannot do this.

Measuring and providing

function Measure({ children }) {
  const ref = useRef(null);
  const [size, setSize] = useState({ width: 0, height: 0 });
  useEffect(() => {
    const observer = new ResizeObserver(([entry]) => {
      setSize({
        width: entry.contentRect.width,
        height: entry.contentRect.height
      });
    });
    if (ref.current) observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);
  return <div ref={ref}>{children(size)}</div>;
}
<Measure>
  {({ width }) => (width > 600 ? <WideChart /> : <NarrowChart />)}
</Measure>

The component must render the element it measures, so the ref and the render are inseparable.

Virtualised lists

<VirtualList
  items={items}
  itemHeight={64}
  renderItem={(item, index) => <Row item={item} index={index} />}
/>

The library owns the scroll container and positioning, you own each row.

You will see this API constantly in libraries, which is a good sign the pattern is right for that job.

Do not overuse it

<UserData>
  {(user) => (
    <Permissions user={user}>
      {(permissions) => (
        <Settings user={user} permissions={permissions}>
          {(settings) => <Panel user={user} settings={settings} />}
        </Settings>
      )}
    </Permissions>
  )}
</UserData>

That is the shape hooks exist to remove. If the components are only providing data, use hooks.

A performance note

<Measure>
  {({ width }) => <ExpensiveChart width={width} />}
</Measure>

A new function every render means the children re-render every time. With the React Compiler this is handled. Without it, and if the child is genuinely expensive, memoize the callback.

The rule

Sharing data or behaviour? Use a hook.

Sharing structure, or wrapping something you must also render? Use a render prop, or children.

Quick recap

  • A render prop is a function prop returning markup.
  • It shares behaviour while letting the caller own the markup.
  • Unlike HOCs, the source of every value is visible.
  • Nesting several of them is the shape hooks removed.
  • For sharing logic, hooks win outright.
  • It still fits when the component owns structure, as in tables and virtualised lists.
  • It is also right when a ref and the rendering cannot be separated.
  • Hook for data, render prop for structure.
script.js

import { useState, useEffect, useRef } from 'react';
// WHERE RENDER PROPS STILL WIN: the component owns the STRUCTURE and
// the caller owns each cell. A hook cannot render anything around you.
function DataTable({ rows, columns, renderRow, renderEmpty }) {
  if (rows.length === 0) return renderEmpty?.() ?? <p>No data</p>;
  return (
    <table>
      <thead>
        <tr>{columns.map((c) => <th key={c.key}>{c.label}</th>)}</tr>
      </thead>
      <tbody>{rows.map((row) => renderRow(row))}</tbody>
    </table>
  );
}
// Also right when a ref and the rendering are inseparable: this
// component must render the very element it measures.
function Measure({ children }) {
  const ref = useRef(null);
  const [size, setSize] = useState({ width: 0, height: 0 });
  useEffect(() => {
    const observer = new ResizeObserver(([entry]) => {
      setSize({
        width: entry.contentRect.width,
        height: entry.contentRect.height
      });
    });
    if (ref.current) observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);
  return <div ref={ref}>{children(size)}</div>;
}
export default function Dashboard({ users, columns }) {
  return (
    <div>
      <DataTable
        rows={users}
        columns={columns}
        renderRow={(user) => (
          <tr key={user.id}>
            <td>{user.name}</td>
            <td><Badge status={user.status} /></td>
          </tr>
        )}
        renderEmpty={() => <EmptyState message="No users yet" />}
      />
      <Measure>
        {({ width }) => (width > 600 ? <WideChart /> : <NarrowChart />)}
      </Measure>
    </div>
  );
}
// WHERE HOOKS WIN: sharing DATA. This nesting is exactly the shape
// hooks were created to remove.
//   <Mouse>
//     {(mouse) => (
//       <Window>
//         {(size) => <Component mouse={mouse} size={size} />}
//       </Window>
//     )}
//   </Mouse>
// Flat, obvious, and it does not get worse as you add more:
function Flat() {
  const mouse = useMousePosition();
  const size = useWindowSize();
  const { theme } = useTheme();
  return <p>Mouse at {mouse.x}, {mouse.y}</p>;
}
// THE RULE: hook for data and behaviour, render prop for structure.

Was this explanation helpful?

Your feedback helps us improve the content for everyone.