Difficulty: Advanced
What is the render props pattern? When is it useful and how does it compare to hooks?
The render props pattern is a technique for sharing code between components using a prop whose value is a function. The component with the render prop handles the behavior (tracking state, managing subscriptions, performing calculations) and calls the function prop to let the consumer decide what to render with that data. It is one of the most elegant patterns in React's history, and understanding it helps you appreciate both the power of composition and why hooks were such a game-changer.
Let me explain it with a concrete example. Imagine you need a component that tracks the mouse position. Multiple components in your app need this data but want to render it differently -- one shows coordinates as text, another uses it to position a follower element, a third uses it for a parallax effect. The render props pattern says: build a MouseTracker component that manages the position state and calls a function prop with the current coordinates. Each consumer provides their own function that receives the coordinates and returns whatever JSX they want.
The pattern comes in two flavors. The 'render prop' flavor uses a named prop: <MouseTracker render={(pos) => <p>{pos.x}, {pos.y}</p>} />. The 'function as children' flavor uses the children prop as a function: <MouseTracker>{(pos) => <p>{pos.x}, {pos.y}</p>}</MouseTracker>. Both are functionally identical -- the only difference is which prop name carries the function. The children version reads more naturally in JSX because it looks like regular nesting.
This pattern is called 'inversion of control' because the component providing the data does not decide how to render it -- the consumer does. The MouseTracker says 'I will track the mouse and call your function with the position.' The consumer says 'When you give me the position, I will render this specific UI.' Neither needs to know about the other's implementation.
Before hooks, render props were the go-to solution for sharing stateful logic. Libraries that used this pattern extensively include Formik (for form state), Downshift (for autocomplete/combobox), React Motion (for animations), and React Router pre-v6 (Route component with render prop).
The problem with render props became apparent when you needed to compose multiple behaviors. If your component needs mouse position, window size, and theme data, you end up with nested render props -- sometimes called 'callback hell' or the 'pyramid of doom.' Each render prop wraps the next, creating deeply indented code that is hard to read and maintain.
Hooks solved this elegantly. Instead of three nested render prop components, you call three hooks: const pos = useMouse(), const size = useWindowSize(), const theme = useTheme(). Flat, readable, composable. This is why hooks replaced render props for most use cases.
However, render props are still alive and relevant in one important area: headless UI components. Libraries like Radix UI, Headless UI, and Downshift use a pattern where a component manages complex behavior (keyboard navigation, ARIA attributes, focus management) and exposes it through render props or the related 'slot' pattern. The consumer gets full control over rendering while the library handles the hard accessibility and interaction logic. This is the modern use case for render props.
One performance consideration: if you pass an inline function as a render prop, it creates a new function reference on every render. For most cases this is fine, but if the component receiving the render prop uses React.memo or shouldComponentUpdate, the new reference will defeat memoization. You can stabilize the reference with useCallback if needed.
// Mouse tracker with render prop
function MouseTracker({ render }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (e) => {
setPosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
// Consumer decides what to render with the data
return render(position);
}
// Usage: different consumers, different UIs
function App() {
return (
<div>
{/* Render as text */}
<MouseTracker render={({ x, y }) => (
<p>Mouse: ({x}, {y})</p>
)} />
{/* Render as a follower element */}
<MouseTracker render={({ x, y }) => (
<div style={{
position: 'fixed',
left: x - 10,
top: y - 10,
width: 20,
height: 20,
borderRadius: '50%',
background: 'blue',
}} />
)} />
</div>
);
}
The MouseTracker component handles the behavior (tracking mouse position). Consumers provide a render function that receives the data and returns whatever UI they want.
// Toggle component with function as children
function Toggle({ children }) {
const [isOn, setIsOn] = useState(false);
const toggle = () => setIsOn(prev => !prev);
// children is a function that receives the state and actions
return children({ isOn, toggle });
}
// Fetch component with loading/error states
function Fetch({ url, children }) {
const [state, setState] = useState({
data: null, loading: true, error: null
});
useEffect(() => {
setState(s => ({ ...s, loading: true }));
fetch(url)
.then(res => res.json())
.then(data => setState({ data, loading: false, error: null }))
.catch(error => setState({ data: null, loading: false, error }));
}, [url]);
return children(state);
}
// Usage: clean and flexible
function App() {
return (
<div>
<Toggle>
{({ isOn, toggle }) => (
<button onClick={toggle}>
{isOn ? 'ON' : 'OFF'}
</button>
)}
</Toggle>
<Fetch url="/api/users">
{({ data, loading, error }) => {
if (loading) return <Spinner />;
if (error) return <p>Error: {error.message}</p>;
return <UserList users={data} />;
}}
</Fetch>
</div>
);
}
Function as children is the most common render props variant. It reads naturally in JSX and provides the consumer with full control over rendering.
// Render Props version - works but nests
function App() {
return (
<WindowSize>
{({ width }) => (
<Theme>
{({ theme }) => (
<Auth>
{({ user }) => (
<Dashboard
width={width}
theme={theme}
user={user}
/>
)}
</Auth>
)}
</Theme>
)}
</WindowSize>
);
}
// Hooks version - flat and clean
function App() {
const { width } = useWindowSize();
const { theme } = useTheme();
const { user } = useAuth();
return (
<Dashboard width={width} theme={theme} user={user} />
);
}
// When render props still shine: headless components
// Libraries like Downshift, React Aria, Radix use this
function Combobox({ items, children }) {
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState(null);
const [highlighted, setHighlighted] = useState(0);
const getInputProps = () => ({
value: selected?.label || '',
onFocus: () => setIsOpen(true),
onKeyDown: (e) => { /* keyboard nav logic */ },
});
const getListProps = () => ({
role: 'listbox',
'aria-expanded': isOpen,
});
// Consumer has full control over rendering
return children({
isOpen, selected, highlighted,
getInputProps, getListProps,
selectItem: setSelected,
});
}
Hooks eliminated the nesting problem of render props. But render props still excel for headless UI components where the consumer needs full control over the DOM structure.
Render Props, Function as Children, Inversion of Control, Component Patterns