Still relevant today?
Before hooks existed, render props and children-as-function were the answer to sharing logic between components. An honest look at where they still offer real value in the age of hooks and where custom hooks are clearly superior.
Table of Contents
- 1. What render props were originally meant to solve
- 2. Children-as-function as a variant
- 3. What hooks changed about the starting point
- 4. The wrapper hell problem with nested render props
- 5. Where render props still make real sense
- 6. Practical example: headless components with render props
- 7. Hybrid APIs: offering a custom hook and a render prop side by side
- 8. When custom hooks are clearly the better choice
- 9. A decision guide for your own codebase
- 10. Summary
- 11. FAQ
1. What render props were originally meant to solve
Before hooks were introduced in React 16.8, there was barely any way to share stateful logic between components without resorting to inheritance or higher-order components. The render props pattern solved this by having a component accept a function as a prop instead of static children, a function the component itself called with internal state or computed values. The calling side then decided freely how to render those values.
A typical MouseTracker component, for example, tracked the mouse position internally and called a render prop with the current coordinates. Any component wanting to use this logic did not have to implement it itself, it could simply act as a consumer following the render prop contract. At the time, that was a real improvement over inheritance chains and confusing HOC nesting.
class MouseTracker extends React.Component {
state = { x: 0, y: 0 };
handleMouseMove = (event) => {
this.setState({ x: event.clientX, y: event.clientY });
};
render() {
return (
<div onMouseMove={this.handleMouseMove}>
{this.props.render(this.state)}
</div>
);
}
}
// Usage
<MouseTracker render={({ x, y }) => (
<p>Mouse at ({x}, {y})</p>
)} />
2. Children-as-function as a variant
A closely related variant uses children itself as a function instead of a separate render prop. Instead of <MouseTracker render={fn} />, you write <MouseTracker>{fn}</MouseTracker>, since React simply treats children as an arbitrary value, including a function. The practical difference is small, but the readability in JSX feels more natural to many, because the structure resembles ordinary component nesting more closely.
Both variants share the same core idea: a component encapsulates logic and state but leaves the rendering entirely to the calling side. The pattern is therefore often described as 'inversion of control', because control over what gets rendered is handed from the logic component to the caller.
function MouseTracker({ children }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
return (
<div onMouseMove={(e) => setPosition({ x: e.clientX, y: e.clientY })}>
{children(position)}
</div>
);
}
// Usage
<MouseTracker>
{({ x, y }) => <p>Mouse at ({x}, {y})</p>}
</MouseTracker>
3. What hooks changed about the starting point
With custom hooks, the same logic reuse can be achieved without introducing an extra wrapper component into the tree. A useMousePosition hook encapsulates exactly the same state logic as the MouseTracker component, but returns the values directly as a return value, without a component having to outsource its own render logic into a foreign function.
The key difference is structural: render props inevitably create an extra layer of components in the tree, with its own lifecycle and its own entry in the React DevTools. A custom hook, by contrast, blends seamlessly into the calling component, no extra nesting occurs, and the code reads linearly instead of functionally nested.
function useMousePosition() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (e) => setPosition({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handleMove);
return () => window.removeEventListener('mousemove', handleMove);
}, []);
return position;
}
// Usage -- no wrapper component needed
function Cursor() {
const { x, y } = useMousePosition();
return <p>Mouse at ({x}, {y})</p>;
}
4. The wrapper hell problem with nested render props
As soon as multiple render props components need to be combined, for example mouse position, window size, and authentication status at the same time, deeply nested JSX quickly emerges, colloquially known as 'wrapper hell' or 'callback hell'. Every additional logic source adds another level of nesting, and the actual rendering code keeps drifting further to the right.
With custom hooks, this problem disappears entirely, because multiple hooks are simply called one after another in the same component, without producing any extra nesting. This is one of the strongest practical reasons custom hooks have become the standard for logic reuse in application code.
// Render props variant: wrapper hell
<MouseTracker>
{(mouse) => (
<WindowSize>
{(size) => (
<Auth>
{(user) => (
<Dashboard mouse={mouse} size={size} user={user} />
)}
</Auth>
)}
</WindowSize>
)}
</MouseTracker>
// Hooks variant: flat, linear
function Dashboard() {
const mouse = useMousePosition();
const size = useWindowSize();
const user = useAuth();
return <DashboardView mouse={mouse} size={size} user={user} />;
}
5. Where render props still make real sense
One area where the pattern has not become obsolete is library APIs, where the rendering of a UI element should deliberately be left to the consuming application, while the library itself only controls behavior and state. Libraries for drag-and-drop data lists, virtualized lists, or form fields frequently use render props for exactly this reason, because a custom hook alone is not enough to also control the DOM structure or event handler binding at a specific point in the tree.
The reason is that a hook can only return values but has no control over the JSX structure itself. A render prop component, on the other hand, can additionally control exactly where in the tree an interaction is registered, for example onMouseDown on a specific wrapper element, which is not possible with plain hooks unless the caller wires it up manually.
6. Practical example: headless components with render props
A concrete example is a sortable table component, where the library manages sorting logic and state but leaves header cell and row rendering entirely to the application. The render prop contract ensures that click handlers and sort status are correctly bound to the right DOM elements, while the visual presentation stays free.
Such 'headless components' are a legitimate and widely used case for render props in practice, because the separation between behavior and presentation matches exactly the core idea of the pattern. Libraries like Downshift for autocomplete behavior historically used exactly this pattern, though many of them now also offer hook-based APIs in parallel.
function SortableTable({ data, renderHeader, renderRow }) {
const [sortKey, setSortKey] = useState(null);
const sorted = useMemo(() => sortData(data, sortKey), [data, sortKey]);
return (
<table>
<thead>{renderHeader({ sortKey, onSort: setSortKey })}</thead>
<tbody>{sorted.map((row) => renderRow(row))}</tbody>
</table>
);
}
7. Hybrid APIs: offering a custom hook and a render prop side by side
Many established libraries do not settle the trade-off permanently in favor of one pattern, they offer both access paths in parallel. Internally, a custom hook encapsulates the actual state logic, and a thin render props or children-as-function component is built merely as a wrapper around that very hook. This way, users who only need plain value access benefit from the lean hook, while users who additionally need DOM control can use the wrapper component, without the library having to maintain the logic twice.
This pattern is especially useful during migration phases, when a library was historically built with render props and existing consumers should not be forced to switch immediately. The hook becomes the actual source of truth, while the render props component stays around as a thin, backward-compatible shell, saving maintenance effort and serving both audiences.
// The hook is the actual source of logic
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue((v) => !v), []);
return [value, toggle];
}
// Render props component as a thin wrapper around the hook
function Toggle({ children, initial }) {
const [value, toggle] = useToggle(initial);
return children({ value, toggle });
}
8. When custom hooks are clearly the better choice
For the vast majority of logic reuse in application code, such as data fetching, form state, media queries, or debouncing, a custom hook is almost always the better choice. It is easier to test, since it can be checked in isolation via renderHook without a rendering context, it creates no extra component tree, and it combines effortlessly with other hooks.
From a TypeScript perspective, hooks are also more pleasant: a hook's return type can be typed precisely, while render props additionally require typing the signature of the callback function, which in practice leads to more type definitions for the same benefit. As soon as the question becomes whether the consuming side also needs control over the concrete DOM structure at several points, the balance tips back toward render props or the headless component pattern.
9. A decision guide for your own codebase
The pragmatic rule of thumb is: if only values or state need to be shared, a custom hook is the right choice. If additional control over specific DOM bindings, event handler placement, or a public, framework-independent library API is needed, the render props or children-as-function pattern remains a valid, deliberate design decision.
It is important not to keep the pattern in your own application code out of habit, just because it was established in an older codebase. Refactoring to custom hooks pays off in most cases as soon as the render props component only serves internal application code and does not represent a public, consuming API.
| Criterion | Render Props / Children Function | Custom Hooks |
|---|---|---|
| Extra component layer in the tree | yes, always | no |
| Combining multiple logic sources | nested (wrapper hell) | flat, linear |
| Control over DOM structure/event binding | yes, controllable from the caller side | only limited |
| Isolated testing without rendering | harder | easy via renderHook |
| Typical use today | headless component libraries | internal application logic reuse |
Mironsoft
React architecture, performance, and Magento frontend integration
React frontends that stay fast instead of slowing down with every feature?
We review existing React applications for unnecessary re-renders, bloated bundles, and fragile state management, then build a frontend that stays performant and connects cleanly to Magento or other backends.
Performance Audit
Systematically measuring and fixing re-renders, bundle size, and load times.
State Architecture
Cleanly separating context, client state, and server state instead of mixing everything.
Magento Integration
Building robust, type-safe GraphQL or REST integration with Magento.
10. Summary
Render Props: The Key Facts at a Glance
Historical purpose
Render props solved logic reuse before hooks were introduced.
Biggest weakness
Nesting multiple render props components leads to wrapper hell.
Modern alternative
Custom hooks for pure state and logic reuse without DOM control.
Remaining use case
Headless component libraries that need DOM structure control.