open atlas
↑ Back to track
React patterns, senior RXP · 03 · 01

Render props

A render prop is a prop whose value returns JSX, sharing behavior while the caller owns the markup — mostly superseded by custom hooks for pure logic, but still right when you must inject something at render time.

RXP Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

You have a Toggle that manages an on/off boolean. One screen wants it to render a switch, another wants a checkbox, a third wants two buttons. The behavior — the state, the flip handler — is identical; only the markup differs. If you bake the markup into Toggle, every new look forks the component. If you copy the state logic into each consumer, you’ve duplicated the behavior.

The render prop is the classic escape from this bind: let Toggle own the behavior and hand it back to the caller, who decides what to draw. It’s the pattern that taught React inversion of control — and even though hooks have absorbed most of its old jobs, there’s a slice of work where a render prop is still the cleaner tool.

Goal

After this lesson you can define a render prop as a prop whose value is a function returning JSX; explain how state and handlers flow from the owning component into the caller’s markup; recognize that custom hooks have superseded render props for pure logic reuse and why; and name the cases where a render prop is still the right call — render-time injection like a virtualized list’s row renderer or a fetcher that renders loading/error/data — plus its failure mode, nested render-prop callback hell.

1

A render prop is a prop whose value is a function that returns JSX; the component calls it to render. Instead of returning fixed markup, the owning component invokes the function and passes its internal state and handlers as arguments. The caller receives them and decides what to draw. children is just a render prop with a special name — passing a function as children is the idiomatic form.

function Toggle({ children }: { children: (on: boolean, toggle: () => void) => React.ReactNode }) {
  const [on, setOn] = useState(false);
  const toggle = () => setOn((v) => !v);
  return <>{children(on, toggle)}</>; // hand state + handler back to the caller
}

// the caller owns the markup; Toggle owns the behavior
<Toggle>
  {(on, toggle) => (
    <button onClick={toggle} aria-pressed={on}>{on ? "On" : "Off"}</button>
  )}
</Toggle>

The component shares what it does without dictating what it looks like.

2

The data flow is an inversion: the child computes state, then calls up to the parent’s function to render it. Normal props flow parent → child. A render prop runs the other way at render time: Toggle is the one holding state, but it delegates the drawing back to whoever rendered it. This is inversion of control — the owning component controls behavior; the caller controls presentation. Two screens can share one Toggle and render wildly different UI.

// screen A — a switch
<Toggle>{(on, toggle) => <Switch checked={on} onChange={toggle} />}</Toggle>

// screen B — a labeled checkbox, same behavior, different markup
<Toggle>{(on, toggle) => (
  <label><input type="checkbox" checked={on} onChange={toggle} /> Notifications</label>
)}</Toggle>

Neither screen reimplements the toggle logic, and Toggle knows nothing about switches or checkboxes.

3

For pure logic reuse, a custom hook does the same job flatter — this is why render props faded. A render prop was historically the only way to share stateful logic without a class HOC. Hooks made the behavior callable directly, with no extra component and no nesting. If all you want is the on/off state, return it from a hook and let the consumer render normally.

function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle] as const;
}

// no wrapper component, no function-child indentation — just values in scope
function NotificationRow() {
  const [on, toggle] = useToggle();
  return <Switch checked={on} onChange={toggle} />;
}

When the shared thing is logic that returns values, prefer the hook. It composes with other hooks, adds no tree depth, and reads top-to-bottom.

4

Render props are still the right tool when the component must inject something at render time. A hook returns values before you render; it cannot decide how each item draws inside a structure the library controls. When the owning component owns the rendering loop — a virtualized list deciding which rows are visible, a fetcher that renders one of loading/error/data branches, a measuring component that knows the box size — it must call back to you during its own render. That’s exactly a render prop.

// the library owns which rows exist this frame; YOU own each row's markup
<VirtualList items={users} rowHeight={48}>
  {(user, index) => <UserRow key={user.id} user={user} index={index} />}
</VirtualList>

// the fetcher owns the loading/error/data state machine; you render each branch
<Query url="/api/users">
  {(state) =>
    state.status === "loading" ? <Spinner />
    : state.status === "error" ? <Error msg={state.error} />
    : <List data={state.data} />}
</Query>

A hook can’t do this cleanly: it doesn’t sit inside the list’s render or the fetcher’s branch switch. Render-time injection is the render prop’s enduring niche.

Worked example

A data fetcher: render prop, then where a hook beats it, then where it doesn’t. Start with a Fetcher that owns the loading/error/data state machine and hands each branch back to the caller.

type State<T> =
  | { status: "loading" }
  | { status: "error"; error: string }
  | { status: "data"; data: T };

function Fetcher<T>({ url, children }: {
  url: string;
  children: (state: State<T>) => React.ReactNode;
}) {
  const [state, setState] = useState<State<T>>({ status: "loading" });
  useEffect(() => {
    let live = true;
    fetch(url)
      .then((r) => r.json())
      .then((data) => live && setState({ status: "data", data }))
      .catch((e) => live && setState({ status: "error", error: String(e) }));
    return () => { live = false; };
  }, [url]);
  return <>{children(state)}</>;
}

If the consumer just needs the data and status as values, a hook is flatter — no wrapper, no indentation, and it composes with other hooks:

function useFetch<T>(url: string) { /* same logic, returns state */ return state; }

function UserList() {
  const state = useFetch<User[]>("/api/users"); // values in scope, render normally
  if (state.status === "loading") return <Spinner />;
  if (state.status === "error") return <Error msg={state.error} />;
  return <List data={state.data} />;
}

So the hook wins here. But now stack the render-prop form three deep — a fetch, inside a virtualized list, inside a measure-the-container component — and the indentation marches right off the screen:

<Measure>{(size) =>
  <Fetcher url="/api/users">{(state) =>
    state.status === "data"
      ? <VirtualList items={state.data} height={size.height}>{(user) =>
          <UserRow user={user} />
        }</VirtualList>
      : <Spinner />
  }</Fetcher>
}</Measure>

That’s the callback hell failure mode. The senior read: Measure, Fetcher, and VirtualList are not equivalent here. Fetcher reduces to useFetch — collapse it. Measure reduces to a useMeasure hook — collapse it too. What’s left is one render prop on VirtualList, which cannot collapse because it injects each row inside a render the list owns. The flat version pulls two of three back to top-level hooks and keeps the one render prop that genuinely needs render-time injection.

Why this works

Why can’t a hook replace the virtualized-list case? A hook runs once at the top of your component and returns values; it has no way to participate in a render loop another component controls. The virtualized list decides — every frame, based on scroll — which indices to mount, and it needs your markup for each of those items at that moment. The only way to give a component “the markup for an item it will decide to render later” is to hand it a function it calls during its own render. That function is a render prop. The same logic applies to anything that owns a structure and asks you to fill slots in it: tables, trees, drag-and-drop, measuring wrappers.

Common mistake

The reflex mistake is treating “render props are mostly superseded” as “render props are obsolete” — and then forcing a hook into a render-time-injection job, ending up re-implementing virtualization or threading a thousand props to fake what a row renderer would do in one line. The inverse mistake is reaching for a render prop when a plain hook (or even plain children/slots) would do, paying the nesting tax for nothing. The discipline: if the shared thing is logic that returns values, use a hook; if the owning component must call you back during its render, use a render prop; if you only need to vary where fixed children go, plain children/slots are enough.

Check yourself
Quiz

You're sharing on/off toggle state across three components that all render it differently but need no library-controlled render loop — just the state and a flip handler. Render prop or custom hook?

Recap

A render prop is a prop whose value is a function returning JSX: the owning component holds state and handlers, then calls that function back during its own render, passing state in so the caller controls the markup. That inversion of control let components share behavior without dictating presentation. For pure logic reuse, custom hooks have superseded render props — a hook returns the same values up-front with no wrapper and no nesting, and composes with other hooks, so prefer it whenever the shared thing is just logic that returns values. Render props remain the right tool for render-time injection: a virtualized list’s row renderer, a fetcher rendering loading/error/data branches, a measuring wrapper — cases where the owning component controls a render loop and must call you back to fill it, which a hook structurally cannot do. The failure mode is nested-render-prop callback hell; the senior fix is to collapse every render prop that’s really just logic into a hook, and keep only the one (or few) that genuinely need render-time injection.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.