Render Props

A render prop is a prop whose value is a function that a component calls in order to decide what to render, instead of hard-coding its own output. It lets you share reusable, stateful logic — tracking the mouse, fetching data, toggling visibility — between components without copying that logic into each one. The component that owns the logic doesn’t dictate the markup; it hands control back to whoever uses it by calling the function it was given, and rendering whatever that function returns. Render props were the standard way to share cross-cutting logic in React before hooks existed, and while custom hooks now cover many of the same use cases, the pattern still appears throughout real codebases and libraries, so it’s worth understanding well.

Overview / How it works

Normally a component decides everything about its own output: it receives props, runs its hooks, and returns JSX. A render-prop component is different — it still owns some behavior (state, event listeners, effects), but instead of deciding the final markup itself, it delegates that decision to a function passed in as a prop. The component calls that function, passing along whatever data the function needs, and simply returns whatever the function returns.

This is a form of inversion of control: the logic-owning component controls when and with what data the function runs, but the caller controls what gets rendered. That separation is exactly what makes the logic reusable — the same <MouseTracker> component can render a tooltip in one place and a custom cursor in another, because the markup lives with the caller, not with <MouseTracker>.

Because the render function is called synchronously inside the owner’s own render phase, there is no extra component boundary involved — from React’s perspective, whatever JSX the function returns simply becomes the owner’s rendered output. That output flows into the normal React lifecycle: React builds a Virtual DOM tree from it, reconciles that tree against the previous one to find the minimal set of changes, and commits those changes to the real DOM. When the owner’s internal state changes (for example, the mouse position updates), React re-renders the owner, which calls the render function again with fresh data, producing new JSX that gets diffed and patched in exactly the same way as any other re-render.

You’ll often see this pattern written with a prop literally named render, but any prop name works as long as its value is a function. A very common variant uses the special children prop as the function instead of a separate named prop — this is sometimes called “function as child” and reads a little more naturally in JSX, since you write the function between the opening and closing tags rather than as an attribute.

Render props predate hooks, and before hooks, the other common way to share logic was the higher-order component (HOC) pattern — a function that wraps a component and injects extra props. Render props avoid some HOC pain points (prop name collisions, unclear “wrapper hell” in dev tools) by making the data flow explicit at the call site. Today, if you only need to share behavior and don’t need the owning component to coordinate markup structure, a custom hook is usually simpler than either pattern — but render props remain valuable when the shared logic needs to hand back more than just values, such as when the caller must decide the shape of the rendered tree itself.

Syntax

The general shape looks like this:

<LogicComponent
  render={(data) => (
    // JSX that uses data
  )}
/>

// or, using children as the function:
<LogicComponent>
  {(data) => (
    // JSX that uses data
  )}
</LogicComponent>
Part Description
LogicComponent The component that owns the reusable state or behavior (mouse position, fetch status, toggle state, and so on).
render (or children) A prop whose value is a function. LogicComponent calls it and returns whatever it returns as its own output.
data The argument (often an object) that LogicComponent passes into the function — typically the current state plus any handler functions the caller needs.
Return value of the function Must be valid JSX (or null), since LogicComponent returns it directly as its own render output.

Examples

Example 1: Tracking the mouse position

import { useState, useEffect } from "react";

function MouseTracker({ render }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    function handleMouseMove(e) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
    window.addEventListener("mousemove", handleMouseMove);
    return () => window.removeEventListener("mousemove", handleMouseMove);
  }, []);

  return render(position);
}

function App() {
  return (
    <MouseTracker
      render={(position) => (
        <p>
          The mouse is at ({position.x}, {position.y})
        </p>
      )}
    />
  );
}

export default App;

Output:

Renders: The mouse is at (0, 0)
(updates live as the cursor moves anywhere over the page)

MouseTracker owns all the state and the event listener; it knows nothing about paragraphs or coordinates formatting. The render function, supplied entirely by App, decides how that position gets displayed. Any other component could reuse MouseTracker and render the same position completely differently — as a custom cursor icon, a tooltip, or a debug overlay.

Example 2: Sharing fetch logic with children as a function

import { useState, useEffect } from "react";

function DataFetcher({ url, children }) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetch(url)
      .then((res) => res.json())
      .then((json) => {
        if (!cancelled) {
          setData(json);
          setLoading(false);
        }
      })
      .catch((err) => {
        if (!cancelled) {
          setError(err);
          setLoading(false);
        }
      });

    return () => {
      cancelled = true;
    };
  }, [url]);

  return children({ data, error, loading });
}

function UserProfile({ userId }) {
  return (
    <DataFetcher url={`https://api.example.com/users/${userId}`}>
      {({ data, error, loading }) => {
        if (loading) return <p>Loading...</p>;
        if (error) return <p>Something went wrong.</p>;
        return <h2>{data.name}</h2>;
      }}
    </DataFetcher>
  );
}

export default UserProfile;

Output:

Renders: Loading...
then either: Something went wrong.
or: an <h2> heading containing the fetched user's name

Here children itself is the function, so no separate render prop is needed — you write the function directly between the opening and closing <DataFetcher> tags. DataFetcher handles loading, error, and cleanup logic once; any component that needs data from a URL can reuse it while fully controlling how loading, error, and success states are displayed.

Example 3: Reusing the same logic for two different UIs

import { useState } from "react";

function Toggle({ render }) {
  const [on, setOn] = useState(false);
  const toggle = () => setOn((prev) => !prev);
  return render({ on, toggle });
}

function Sidebar() {
  return (
    <Toggle
      render={({ on, toggle }) => (
        <div>
          <button onClick={toggle}>{on ? "Hide" : "Show"} details</button>
          {on && <p>Here are the extra details you wanted to see.</p>}
        </div>
      )}
    />
  );
}

function ReadMore({ text }) {
  return (
    <Toggle
      render={({ on, toggle }) => (
        <p>
          {on ? text : `${text.slice(0, 40)}...`}{" "}
          <button onClick={toggle}>{on ? "Show less" : "Read more"}</button>
        </p>
      )}
    />
  );
}

function App() {
  return (
    <>
      <Sidebar />
      <ReadMore text="Render props let you share stateful logic between components by passing a function as a prop." />
    </>
  );
}

export default App;

Output:

Renders: a "Show details" button that reveals a paragraph and
switches to "Hide details" when clicked, plus a separate
truncated paragraph with a "Read more" button that expands to
the full text and toggles to "Show less"

This is the payoff of the pattern: Toggle knows nothing about sidebars or truncated text — it only manages an on/off boolean and a way to flip it. Sidebar and ReadMore reuse the exact same stateful logic while rendering completely different markup around it.

How it works step by step (under the hood)

On mount: React calls the owner component’s function (for example, MouseTracker). Its hooks run in order — useState initializes position — and the function reaches return render(position). Since render is just a plain function, React invokes it immediately and synchronously while evaluating MouseTracker‘s own output; there is no separate component boundary from React’s point of view beyond the elements returned. That JSX becomes MouseTracker‘s rendered output, gets reconciled into the Virtual DOM, and committed to the real DOM. After the commit, the useEffect body runs and attaches the mousemove listener.

On update: When the mouse moves, the event handler calls setPosition with a new object, which schedules a re-render of MouseTracker. Its function body runs again top to bottom; hooks return values in the same order as before (this is exactly why hooks must never be called conditionally — React matches hook calls to their state by call order, not by name). render(position) is invoked again with the fresh coordinates, producing new JSX. React diffs this against the previous output and patches only what changed — in this example, just the text node inside the <p>.

On unmount: When MouseTracker is removed from the tree, React runs the cleanup function returned from useEffect, removing the mousemove listener, before discarding the component instance entirely.

Common Mistakes

Mistake 1: Passing JSX instead of a function

function App() {
  const position = { x: 0, y: 0 };
  return <MouseTracker render={<p>{position.x}, {position.y}</p>} />;
}

This is wrong because MouseTracker calls render(position) expecting a function — here render is a JSX element, not a function, so calling it throws a runtime error (“render is not a function”). It also only ever describes a fixed, hard-coded position variable rather than the live value MouseTracker actually tracks internally. Fix it by passing an arrow function that receives the live data as its argument:

function App() {
  return (
    <MouseTracker
      render={(position) => (
        <p>{position.x}, {position.y}</p>
      )}
    />
  );
}

Mistake 2: Forgetting the key when rendering a list inside a render prop

function List({ items, renderItem }) {
  return (
    <ul>
      {items.map((item) => (
        <li>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

There’s no key prop on the <li>, so React can’t reliably match list items across re-renders — after items are reordered, filtered, or removed, React may reuse the wrong DOM node’s internal state, and the console will warn about the missing key. The rule applies just as much inside a render-prop helper as anywhere else: every element produced from an array needs a stable, unique key.

function List({ items, renderItem }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

Best Practices

  • Name the prop clearly — render or renderItem for a named prop, or use children as the function when there’s only one render slot; the latter usually reads more naturally in JSX.
  • Keep the render function pure: it should compute JSX from its arguments only, without triggering side effects or calling setters during render.
  • Reach for a custom hook instead when you only need to share state and behavior, not coordinate how markup is structured — a hook is simpler to read and doesn’t add a wrapping component.
  • If the logic-owning component is wrapped in React.memo for performance, memoize the function you pass with useCallback; otherwise a brand-new function on every render defeats the memoization.
  • Document (or type, in TypeScript) the exact shape of the object your render function receives, so consumers know what data and handlers are available.
  • Avoid nesting several render props inside one another (“render prop hell”); if you need to combine multiple pieces of shared logic, prefer composing custom hooks inside one component instead.

Practice Exercises

  • Build a <Hover render={...} /> component that tracks whether the pointer is currently over an element (using onMouseEnter and onMouseLeave) and passes { hovering } to its render prop. Use it to swap an image’s caption when hovered.
  • Extend the DataFetcher example to fetch a list of posts, and combine it with the fixed List component from the Common Mistakes section (remember the key!) to render each post’s title.
  • Refactor Toggle‘s internal logic into a custom hook called useToggle that returns [on, toggle], then rewrite Sidebar to call the hook directly instead of using the render prop. Compare which version is easier to read for this particular component.

Summary

  • A render prop is a function passed as a prop that a component calls to decide what to render, keeping shared logic and specific markup in separate components.
  • The render function runs synchronously inside the owner’s own render phase, so its returned JSX is reconciled and committed as part of that same render cycle.
  • children can itself act as the render prop — the “function as child” variant — by making children a function instead of JSX.
  • Since hooks were introduced, custom hooks often replace render props when only stateful behavior (not markup coordination) needs to be shared.
  • Always pass an actual function (never JSX) to a render prop, always add a key to list items rendered through it, and keep the function reasonably stable if the owner is memoized.