Render props pattern in React
Render props pattern is a technique where a component accepts a function as a prop, calls it with its own internal state or data, and returns whatever JSX that function produces.
Theory
TL;DR
- Like a vending machine with a display slot: the machine tracks inventory (state), you supply what shows in the slot (the render function)
- The component owns logic and state; the caller owns the markup
- A render prop can be any prop name, including
childrenwhen used as a function - Works in class and function components; unlike hooks, it predates React 16.8
- For new functional code, a custom hook usually replaces the pattern cleanly
Quick example
// MouseTracker owns state; the caller decides what to render
function MouseTracker({ render }: {
render: (pos: { x: number; y: number }) => React.ReactNode;
}) {
const [pos, setPos] = React.useState({ x: 0, y: 0 });
return (
<div onMouseMove={e => setPos({ x: e.clientX, y: e.clientY })} style={{ height: '100vh' }}>
{render(pos)} {/* Calls your function with current position */}
</div>
);
}
// Two UIs, same tracking logic - no duplication
<MouseTracker render={({ x, y }) => <h1>Position: {x}, {y}</h1>} />MouseTracker handles the event listener and state update. You get the coordinates and decide what to show. That division is the whole point.
How it differs from plain children and HOCs
Regular children is JSX - React renders it as-is. A render prop is a function you call with data inside the component. The function runs in the component's closure, so it has access to state the parent never touches directly.
HOCs wrap components and stack layers in the tree. Render props inject data into the caller's scope via a function call. The tree stays flatter and the data flow is explicit in JSX.
When to use
- Same stateful logic, different UIs: render props let each consumer render independently
- Class components or legacy codebases where hooks are not available
- Building libraries where consumers need full rendering control (Downshift, React Virtualized)
- Avoiding HOC wrapper stacks that obscure your component tree in DevTools
For new code in function components, useMousePosition() is cleaner than <MouseTracker render={...} />. Reach for hooks first; render props when the situation calls for them.
Render props vs custom hooks
| Render props | Custom hooks | |
|---|---|---|
| Shares logic | Yes | Yes |
| Adds a wrapper to the tree | Yes | No |
| Works in class components | Yes | No |
| Consumer controls rendering | Yes | No - returns data, you render |
| Readability with nesting | Can get deep | Cleaner at call site |
Neither is always better. Render props win when you ship a UI library that needs caller control over rendering. Hooks win for everything else in modern React.
How React handles the render prop call
React treats the render prop as a plain function during reconciliation. On each render pass, it captures current state, calls your function (a standard closure over component scope), and diffs the returned JSX against the previous output. No special optimization runs here.
One direct consequence: an inline arrow function creates a new reference on every parent render. Child components using React.memo will re-render because they see a new prop, even when data has not changed. useCallback on the render function fixes this.
Common mistakes
Mutating the data passed to the render prop
// Wrong - props are immutable; this corrupts parent state
<MouseTracker render={pos => { pos.x = 100; return <p>{pos.x}</p>; }} />
// Right - spread into a new object
<MouseTracker render={pos => { const shifted = { ...pos, x: pos.x + 100 }; return <p>{shifted.x}</p>; }} />Inline function with an expensive child
// Creates a new fn reference every time App re-renders
function App() {
return <WindowSize render={size => <Chart data={size} />} />;
}
// Stabilize with useCallback
function App() {
const renderChart = React.useCallback(
(size: { width: number; height: number }) => <Chart data={size} />,
[]
);
return <WindowSize render={renderChart} />;
}Typing children as ReactNode when it should be a function
// Wrong - children is never called with data
function Tracker({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
// Right
function Tracker({ children }: {
children: (pos: { x: number; y: number }) => React.ReactNode;
}) {
const [pos, setPos] = React.useState({ x: 0, y: 0 });
return (
<div onMouseMove={e => setPos({ x: e.clientX, y: e.clientY })}>
{children(pos)}
</div>
);
}Unbound this in class component render props
// Wrong - may throw or produce stale data
class BadTracker extends React.Component<{ render: (pos: any) => React.ReactNode }> {
render() { return this.props.render(this.state); }
}
// Right - use an arrow class field
class GoodTracker extends React.Component<{ render: (pos: any) => React.ReactNode }> {
state = { x: 0, y: 0 };
handleMove = (e: React.MouseEvent) => this.setState({ x: e.clientX, y: e.clientY });
render() { return <div onMouseMove={this.handleMove}>{this.props.render(this.state)}</div>; }
}Real-world usage
- React Router v5:
<Route render={({ location }) => <Component loc={location} />} />- the pre-hooks API for accessing route data - Downshift:
render={({ isOpen, getInputProps }) => ...}- full autocomplete rendering handed to the consumer - React Virtualized: row rendering via render prop so callers control how each row looks
- React Motion: animation state exposed via render prop; you decide what the animated element is
- Formik (early versions): used this pattern for field rendering before moving to hooks
Follow-up questions
Q: What is the difference between a render prop and children as a function?
A: Functionally the same. The difference is ergonomics: a named render prop is explicit and allows multiple function props on one component. children as a function is syntactically cleaner for single-slot cases but can surprise developers who expect JSX children.
Q: How does render props compare to HOCs?
A: HOCs add components to the tree; render props inject data via a function call. With HOCs you can hit wrapper hell and prop name collisions. Render props make the data flow visible directly in JSX.
Q: How would you convert this pattern to a hook?
A: Extract the state and effects into a custom hook and return the data: function useMousePosition() { ... return pos; }. Cleaner at the call site, but you lose the ability to wrap rendering behavior inside a component.
Q: Why not replace render props with hooks everywhere?
A: Hooks don't work in class components. Library authors also use render props when they want to give the consumer full rendering control, not just data. React Router v5 and Downshift are real examples of this choice.
Q: What causes unnecessary re-renders with render props, and how do you fix it?
A: An inline render function creates a new function reference on every parent render. If the child uses React.memo, it sees a new prop and re-renders even when the actual data is identical. Stabilize the function with useCallback.
Examples
Basic: mouse position tracker
import React from 'react';
function MouseTracker({ render }: {
render: (pos: { x: number; y: number }) => React.ReactNode;
}) {
const [pos, setPos] = React.useState({ x: 0, y: 0 });
return (
<div
onMouseMove={e => setPos({ x: e.clientX, y: e.clientY })}
style={{ height: '100vh' }}
>
{render(pos)}
</div>
);
}
// Same tracker, two different UIs - no logic duplication
<MouseTracker render={({ x, y }) => <p>Coordinates: {x}, {y}</p>} />
<MouseTracker render={({ x, y }) => (
<div style={{ position: 'absolute', left: x, top: y }}>Cat</div>
)} />Both consumers share one event listener setup. The render prop swaps out the visual output without touching the tracking logic.
Intermediate: data fetcher with async states
function UserFetcher({ userId, render }: {
userId: string;
render: (state: { user: any | null; loading: boolean; error: string | null }) => React.ReactNode;
}) {
const [state, setState] = React.useState({ user: null, loading: true, error: null });
React.useEffect(() => {
setState({ user: null, loading: true, error: null });
fetch(`/api/user/${userId}`)
.then(res => res.json())
.then(user => setState({ user, loading: false, error: null }))
.catch(err => setState({ user: null, loading: false, error: err.message }));
}, [userId]);
return <>{render(state)}</>;
}
// Dashboard controls its own UI, fetcher handles async lifecycle
<UserFetcher userId="42" render={({ user, loading, error }) => {
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>Hello, {user.name}!</div>;
}} />UserFetcher owns the async lifecycle and state transitions. The caller decides how loading, error, and success look. I've seen this exact pattern used for every API call in pre-hooks codebases, and it holds up well even now.
Advanced: window size with memoized render prop
function WindowSize({ render }: {
render: (size: { width: number; height: number }) => React.ReactNode;
}) {
const [size, setSize] = React.useState({
width: window.innerWidth,
height: window.innerHeight,
});
React.useEffect(() => {
const update = () => setSize({ width: window.innerWidth, height: window.innerHeight });
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return <>{render(size)}</>;
}
// Wrong: new function reference on every Dashboard render
function Dashboard() {
return <WindowSize render={size => <Chart data={size} />} />;
}
// Right: stable reference with useCallback
function Dashboard() {
const renderChart = React.useCallback(
(size: { width: number; height: number }) => <Chart data={size} />,
[] // stable - no deps change
);
return <WindowSize render={renderChart} />;
}On resize, WindowSize updates state and calls render. With a stable renderChart reference, Chart only re-renders when the window actually changes size, not on every unrelated Dashboard re-render.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.