Render Props Pattern in React
Share React logic with render props — function props, children-as-function, mouse tracking and Toggle examples, use cases, pitfalls, and when custom hooks replace the pattern.
Introduction
The render props pattern passes a function as a prop (often render or children) that receives shared state from a parent component and returns JSX. The parent owns the how — data fetching, subscriptions, toggles — while the consumer decides the what — markup, styling, and layout.
This guide covers:
- Definition — function props, dynamic rendering, logic/UI split
- Problem — duplicated logic across components
- Implementation —
renderprop vschildrenas function - Use cases — mouse tracking, data fetching, libraries, legacy code
- Pitfalls — performance, nesting, DevTools noise
- Modern alternatives — custom hooks and HOCs
- Practice task — reusable
Togglewith consumer-defined UI
Quick index
1. Definition & concept
A render-prop component accepts a function that returns JSX instead of static children. The parent calls that function with internal state — the consumer paints whatever UI fits.
| Concept | Meaning |
|---|---|
| Prop expects a function | render, children, or any callback prop |
| Function returns JSX | (state) => <UI /> |
| Dynamic rendering control | Consumer chooses markup per call site |
| Separates how from what | Parent = logic; render prop = presentation |
'use client'
import { useState, type ReactNode } from 'react'
type CounterRenderProps = {
count: number
increment: () => void
decrement: () => void
}
// Parent owns logic
function Counter({ initial = 0, children }: { initial?: number; children: (props: CounterRenderProps) => ReactNode }) {
const [count, setCount] = useState(initial)
return (
<>
{children({
count,
increment: () => setCount((c) => c + 1),
decrement: () => setCount((c) => c - 1),
})}
</>
)
}
// Consumer owns UI
function App() {
return (
<Counter initial={10}>
{({ count, increment, decrement }) => (
<div className="flex items-center gap-2">
<button type="button" onClick={decrement}>
−
</button>
<span className="font-mono">{count}</span>
<button type="button" onClick={increment}>
+
</button>
</div>
)}
</Counter>
)
}2. Problem: logic duplication
Without shared logic extraction, every component that needs mouse coordinates (or fetch state, or window size) copy-pastes the same useEffect.
Before — duplicated mouse logic
'use client'
import { useEffect, useState } from 'react'
function CarIcon() {
const [pos, setPos] = useState({ x: 0, y: 0 })
useEffect(() => {
const move = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY })
window.addEventListener('mousemove', move)
return () => window.removeEventListener('mousemove', move)
}, [])
return <span style={{ position: 'fixed', left: pos.x, top: pos.y }}>🚗</span>
}
function BikeIcon() {
const [pos, setPos] = useState({ x: 0, y: 0 })
useEffect(() => {
const move = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY })
window.addEventListener('mousemove', move)
return () => window.removeEventListener('mousemove', move)
}, [])
return <span style={{ position: 'fixed', left: pos.x + 20, top: pos.y + 20 }}>🚲</span>
}| Smell | Impact |
|---|---|
Identical useEffect in Car and Bike | Bug fixes must land twice |
| Logic mixed with emoji presentation | Hard to unit-test coordinates without DOM |
| Adding a third icon copies a third time | Maintenance cost scales linearly |
3. Implementation approaches
Two equivalent APIs — pick one per component family and document it.
Standard render prop
Explicit prop name — clear in TypeScript, works when children must remain static nodes.
'use client'
import { useEffect, useState, type ReactNode } from 'react'
type Point = { x: number; y: number }
function MouseTracker({ render }: { render: (pos: Point) => ReactNode }) {
const [pos, setPos] = useState<Point>({ x: 0, y: 0 })
useEffect(() => {
const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY })
window.addEventListener('mousemove', handler)
return () => window.removeEventListener('mousemove', handler)
}, [])
return <>{render(pos)}</>
}
// Usage
;<MouseTracker
render={(pos) => (
<p>
Cursor: {pos.x}, {pos.y}
</p>
)}
/>children as function (function-as-child)
Same mechanics — children is the render function. Reads naturally in JSX.
function MouseTracker({ children }: { children: (pos: Point) => ReactNode }) {
// … same state/effect …
return <>{children(pos)}</>
}
// Usage — composition inside tags
;<MouseTracker>
{(pos) => (
<span className="fixed top-4 right-4 rounded bg-black px-2 py-1 text-white">
{pos.x}, {pos.y}
</span>
)}
</MouseTracker>| Approach | When to prefer |
|---|---|
render prop | Need static children alongside dynamic UI; multiple render slots (renderHeader, renderBody) |
children function | Single injection point; reads like a scoped template |
4. Example: mouse tracker
Extract shared tracking once; Car and Bike only differ in presentation.
'use client'
import { useEffect, useState, type ReactNode } from 'react'
type Point = { x: number; y: number }
export function MouseTracker({ children }: { children: (pos: Point) => ReactNode }) {
const [pos, setPos] = useState<Point>({ x: 0, y: 0 })
useEffect(() => {
const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY })
window.addEventListener('mousemove', handler)
return () => window.removeEventListener('mousemove', handler)
}, [])
return <>{children(pos)}</>
}// Car — offset cursor by (0, 0)
<MouseTracker>
{(pos) => (
<span style={{ position: 'fixed', left: pos.x, top: pos.y, pointerEvents: 'none' }}>
🚗
</span>
)}
</MouseTracker>
// Bike — different offset and label
<MouseTracker>
{(pos) => (
<span
style={{ position: 'fixed', left: pos.x + 24, top: pos.y + 24, pointerEvents: 'none' }}
aria-label={`Bike at ${pos.x}, ${pos.y}`}
>
🚲
</span>
)}
</MouseTracker>5. Example: data fetching
Render props expose loading, error, and data without prescribing table vs card layout.
'use client'
import { useEffect, useState, type ReactNode } from 'react'
type FetchState<T> = { status: 'loading' } | { status: 'error'; error: string } | { status: 'success'; data: T }
function DataFetcher<T>({ url, children }: { url: string; children: (state: FetchState<T>) => ReactNode }) {
const [state, setState] = useState<FetchState<T>>({ status: 'loading' })
useEffect(() => {
let cancelled = false
setState({ status: 'loading' })
fetch(url)
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json()
})
.then((data: T) => {
if (!cancelled) setState({ status: 'success', data })
})
.catch((e: Error) => {
if (!cancelled) setState({ status: 'error', error: e.message })
})
return () => {
cancelled = true
}
}, [url])
return <>{children(state)}</>
}type User = { id: string; name: string }
// Table layout
<DataFetcher<User[]> url="/api/users">
{(state) => {
if (state.status === 'loading') return <p>Loading users…</p>
if (state.status === 'error') return <p>Error: {state.error}</p>
return (
<ul>
{state.data.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
)
}}
</DataFetcher>
// Card grid — same fetch logic, different UI
<DataFetcher<User[]> url="/api/users">
{(state) => {
if (state.status !== 'success') return null
return (
<div className="grid grid-cols-3 gap-4">
{state.data.map((u) => (
<article key={u.id} className="rounded border p-4">{u.name}</article>
))}
</div>
)
}}
</DataFetcher>6. Use cases
| Use case | Why render props fit |
|---|---|
| Sharing logic | Mouse position, window size, geolocation, media queries |
| Data fetching (legacy) | Pre-hooks libraries (react-request, older Apollo patterns) |
| Reusable component libraries | Consumer controls markup inside a behavior shell |
| Legacy codebases | Class components and pre-2018 patterns still in production |
| Org modernization constraints | Incremental migration when hooks adoption is phased |
// Window width — consumer picks breakpoint UI
<WindowSize>{(width) => (width < 768 ? <MobileNav /> : <DesktopNav />)}</WindowSize>7. Practice task: Toggle
Build a Toggle that owns isOpen state and exposes toggle — the consumer decides switch vs button vs accordion trigger.
'use client'
import { useCallback, useState, type ReactNode } from 'react'
export type ToggleRenderProps = {
isOpen: boolean
toggle: () => void
open: () => void
close: () => void
}
type ToggleProps = {
defaultOpen?: boolean
children: (props: ToggleRenderProps) => ReactNode
}
export function Toggle({ defaultOpen = false, children }: ToggleProps) {
const [isOpen, setIsOpen] = useState(defaultOpen)
const open = useCallback(() => setIsOpen(true), [])
const close = useCallback(() => setIsOpen(false), [])
const toggle = useCallback(() => setIsOpen((v) => !v), [])
return <>{children({ isOpen, toggle, open, close })}</>
}Consumer A — switch UI
<Toggle defaultOpen={false}>
{({ isOpen, toggle }) => (
<button
type="button"
role="switch"
aria-checked={isOpen}
onClick={toggle}
className={`h-6 w-11 rounded-full ${isOpen ? 'bg-green-500' : 'bg-neutral-300'}`}>
<span className={`block h-5 w-5 rounded-full bg-white transition ${isOpen ? 'translate-x-5' : ''}`} />
</button>
)}
</Toggle>Consumer B — disclosure panel
<Toggle>
{({ isOpen, toggle }) => (
<div>
<button type="button" aria-expanded={isOpen} onClick={toggle}>
{isOpen ? 'Hide details' : 'Show details'}
</button>
{isOpen && (
<div className="mt-2 rounded border p-4 text-sm">Extra content the Toggle component knows nothing about…</div>
)}
</div>
)}
</Toggle>Consumer C — both render and children variants
// Optional dual API for libraries
function ToggleDual({
defaultOpen = false,
render,
children,
}: {
defaultOpen?: boolean
render?: (props: ToggleRenderProps) => ReactNode
children?: (props: ToggleRenderProps) => ReactNode
}) {
const fn = render ?? children
if (!fn) throw new Error('Toggle requires render or children function')
// … same state as Toggle …
return <>{fn({ isOpen, toggle, open, close })}</>
}8. Pitfalls & limitations
| Pitfall | Problem | Mitigation |
|---|---|---|
| Inline functions in render | New function reference every parent render → child re-renders | Extract render callback; useCallback in consumer |
| Nested render props | "Callback hell" in JSX — hard to read | Extract sub-component or switch to hooks |
| DevTools noise | Anonymous function components stack deeply | Name render callbacks: function UserList(state) { … } |
| Mixing with HOCs | withX(withY(<RenderProp />)) — wrapper hell | Pick one abstraction per feature |
| Testing | Must mount wrapper to test UI | Test logic via hook extraction; snapshot render prop output |
Nested render props — anti-pattern
// ❌ Hard to read — pyramid of doom
<DataFetcher url="/api/a">
{(a) => (
<DataFetcher url="/api/b">
{(b) => <MouseTracker>{(pos) => <Dashboard a={a} b={b} pos={pos} />}</MouseTracker>}
</DataFetcher>
)}
</DataFetcher>// ✅ Extract hook + flat JSX
function DashboardPage() {
const a = useFetch('/api/a')
const b = useFetch('/api/b')
const pos = useMousePosition()
return <Dashboard a={a} b={b} pos={pos} />
}9. Modern alternatives
Render props solved logic reuse before hooks. Today, three patterns compete:
| Pattern | Mechanism | Status |
|---|---|---|
| Custom hooks | useMousePosition() — call in any component | ✅ Standard for app code |
| Render props | Function prop returns JSX | ✅ Libraries, legacy, max UI flexibility |
| HOCs | withAuth(Component) wraps exports | ⚠️ Legacy — ref forwarding pain |
| Compound components | Context + dot notation | ✅ Design systems — structure over function props |
Same Toggle — hook version
'use client'
import { useCallback, useState } from 'react'
export function useToggle(defaultOpen = false) {
const [isOpen, setIsOpen] = useState(defaultOpen)
const toggle = useCallback(() => setIsOpen((v) => !v), [])
const open = useCallback(() => setIsOpen(true), [])
const close = useCallback(() => setIsOpen(false), [])
return { isOpen, toggle, open, close }
}
// Consumer — no wrapper component
function NotificationsPanel() {
const { isOpen, toggle } = useToggle()
return (
<button type="button" aria-expanded={isOpen} onClick={toggle}>
{isOpen ? 'Close' : 'Open'} notifications
</button>
)
}Same mouse logic — hook version
function useMousePosition() {
const [pos, setPos] = useState({ x: 0, y: 0 })
useEffect(() => {
const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY })
window.addEventListener('mousemove', handler)
return () => window.removeEventListener('mousemove', handler)
}, [])
return pos
}
function CarIcon() {
const pos = useMousePosition()
return <span style={{ position: 'fixed', left: pos.x, top: pos.y }}>🚗</span>
}Summary
| Idea | Render props | Custom hooks (modern) |
|---|---|---|
| Logic location | Inside wrapper component | useX() in any component |
| UI control | Function returns JSX | Local JSX in caller |
| Best for | Libraries, legacy, flexible injection | Application features |
Render props separate how (logic) from what (UI) by passing a function that receives shared state. They fix logic duplication — mouse tracking, toggles, fetch shells — but nested pyramids and inline callbacks are real costs. Default to custom hooks in app code; keep render props where consumers need maximum rendering control.
Next reads: Compound Components Pattern for Context-based composition, and React Design Patterns for HOCs and the full roadmap.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime