Render Props: Share Logic Through Function Children
The render props pattern passes a component's rendering logic to a function (called a "render prop") that returns JSX. This function receives the component's state or methods as arguments, letting consumers decide exactly how to render the content while the provider component handles all the complex behavior. A Loader component might pass isLoading and data to a render function; the consumer can show a spinner, cache the data, or build custom UI—all without the Loader knowing details about any of them.
I've seen render props reduce integration friction by orders of magnitude. A data-fetching component that hardcoded one loading state and one error state became useless in edge cases; switching to render props let it adapt to any loading pattern in minutes.
The Core Pattern: Function Children
Basic Render Prop Shape
// A simple render prop component: it manages state and calls a function
function MouseTracker({ render }) {
const [position, setPosition] = React.useState({ x: 0, y: 0 });
const handleMouseMove = (e) => {
setPosition({ x: e.clientX, y: e.clientY });
};
return (
<div onMouseMove={handleMouseMove}>
{/* Call render as a function, passing state as an argument */}
{render(position)}
</div>
);
}
// Consumer passes a function that decides how to render
export default function App() {
return (
<MouseTracker
render={({ x, y }) => (
<div>
The mouse is at ({x}, {y})
</div>
)}
/>
);
}
The MouseTracker component tracks the mouse; the consumer's render function decides what to show. Same component, infinite rendering possibilities.
Render Prop vs. Children Prop
Sometimes the render function is the children:
// Equivalent using children instead of a render prop
function MouseTracker({ children }) {
const [position, setPosition] = React.useState({ x: 0, y: 0 });
const handleMouseMove = (e) => {
setPosition({ x: e.clientX, y: e.clientY });
};
return (
<div onMouseMove={handleMouseMove}>
{/* children is a function */}
{children(position)}
</div>
);
}
// Usage: children as a function (function-as-child pattern)
export default function App() {
return (
<MouseTracker>
{({ x, y }) => (
<div>Mouse at ({x}, {y})</div>
)}
</MouseTracker>
);
}
Both styles work; function-as-child is more intuitive if the render function is the primary use case.
Real-World: Data-Fetching Component
Here's how render props shine when managing complex async state:
// A fetch component that handles loading, error, and success states
function useFetch(url) {
const [state, setState] = React.useState({
data: null,
isLoading: true,
error: null,
});
React.useEffect(() => {
let isMounted = true;
fetch(url)
.then(res => res.json())
.then(data => {
if (isMounted) setState({ data, isLoading: false, error: null });
})
.catch(error => {
if (isMounted) setState({ data: null, isLoading: false, error });
});
return () => { isMounted = false; };
}, [url]);
return state;
}
// Render prop component wrapping the hook
function DataFetcher({ url, children, onRetry }) {
const { data, isLoading, error } = useFetch(url);
return children({
data,
isLoading,
error,
retry: () => onRetry?.(),
});
}
// Example: consumer handles all UI logic
function PostsList() {
return (
<DataFetcher url="/api/posts" onRetry={() => window.location.reload()}>
{({ data, isLoading, error, retry }) => {
if (isLoading) return <div>Loading posts...</div>;
if (error) return (
<div>
Failed to load: {error.message}
<button onClick={retry}>Retry</button>
</div>
);
if (!data?.length) return <div>No posts found</div>;
return (
<ul>
{data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}}
</DataFetcher>
);
}
The DataFetcher doesn't know whether you want a spinner, skeleton, or nothing during loading. It just passes the state to the render function and gets out of the way.
Conditional Rendering with Render Props
Render props make conditional rendering explicit and composable:
// A form validation helper using render props
function FormField({ name, validate, children }) {
const [value, setValue] = React.useState('');
const [error, setError] = React.useState(null);
const [touched, setTouched] = React.useState(false);
const handleChange = (e) => {
const val = e.target.value;
setValue(val);
// Validate on change
const err = validate(val);
setError(err);
};
const handleBlur = () => {
setTouched(true);
};
return children({
value,
error: touched ? error : null,
onChange: handleChange,
onBlur: handleBlur,
});
}
// Usage: render prop decides how to show error state
function SignupForm() {
return (
<form>
<FormField
name="email"
validate={(val) => val.includes('@') ? null : 'Invalid email'}
>
{({ value, error, onChange, onBlur }) => (
<div>
<input
value={value}
onChange={onChange}
onBlur={onBlur}
aria-invalid={!!error}
/>
{error && <span className="error">{error}</span>}
</div>
)}
</FormField>
</form>
);
}
The FormField handles validation logic; the consumer's render prop decides whether to show the error inline, in a tooltip, or in a summary.
Combining Multiple Render Props
For complex components, you might accept multiple render functions:
function DataGrid({ data, renderHeader, renderRow, renderFooter }) {
return (
<table>
<thead>{renderHeader(data)}</thead>
<tbody>
{data.map((row, idx) => (
<tr key={idx}>{renderRow(row, idx)}</tr>
))}
</tbody>
{renderFooter && <tfoot>{renderFooter(data)}</tfoot>}
</table>
);
}
// Each render prop controls one part of the grid
<DataGrid
data={users}
renderHeader={() => <tr><th>Name</th><th>Email</th></tr>}
renderRow={(user) => (
<>
<td>{user.name}</td>
<td>{user.email}</td>
</>
)}
renderFooter={(data) => (
<tr><td colSpan={2}>Total: {data.length}</td></tr>
)}
/>
Key Takeaways
- Render props pass a function to a component that receives state and methods as arguments, letting consumers control rendering.
- This pattern decouples behavior (fetching, validation, state) from presentation, making components flexible and reusable.
- Function-as-children is a variant where the render function is the
childrenprop—often more intuitive. - Render props work especially well for data-fetching, form handling, and any scenario where multiple consumers need different UI.
- Combining multiple render props lets you control different parts of a component's output independently.
Frequently Asked Questions
Isn't render props less readable than hooks?
Render props require callback nesting, which can be harder to read than imperative hooks. However, for shared component logic (not just extracting to a hook), render props make state and side effects explicit at the call site. Many projects use both—hooks for internal logic, render props for flexible composition.
Should I use render props or headless components?
Render props are the pattern; a headless component is the result—a component designed with render props to give consumers full control. Both terms are used interchangeably. The key is the usage pattern, not the naming.
Can I use render props with TypeScript?
Yes. Type the render function with a union or generics. Example: render: (state: { data: T; isLoading: boolean }) => React.ReactNode. TypeScript ensures the render function's parameters match what the component passes.
What's the performance impact of render props?
Each render creates a new function. If the render prop causes the component to re-render unnecessarily, you can use React.useMemo to memoize it, or move it outside the component definition to stabilize identity.
Does React.Children work with render props?
Yes, you can inspect the render function with React.Children.map, but it's rare. Render props are designed to be called, not inspected. For inspecting children structure, use compound components instead.