Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
ReactDesign PatternsRender PropsCustom HooksLogic Reuse

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.

Jul 4, 202614 min read

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:

  1. Definition — function props, dynamic rendering, logic/UI split
  2. Problem — duplicated logic across components
  3. Implementation — render prop vs children as function
  4. Use cases — mouse tracking, data fetching, libraries, legacy code
  5. Pitfalls — performance, nesting, DevTools noise
  6. Modern alternatives — custom hooks and HOCs
  7. Practice task — reusable Toggle with consumer-defined UI

Note

Render props were the dominant React reuse pattern before hooks (2018). They remain valid in libraries and legacy codebases — but most app logic today belongs in custom hooks.

Quick index

#Section
1Definition & concept
2Problem: logic duplication
3Implementation approaches
4Example: mouse tracker
5Example: data fetching
6Use cases
7Practice task: Toggle
8Pitfalls & limitations
9Modern alternatives

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.

ConceptMeaning
Prop expects a functionrender, children, or any callback prop
Function returns JSX(state) => <UI />
Dynamic rendering controlConsumer chooses markup per call site
Separates how from whatParent = logic; render prop = presentation
tsx
'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>
  )
}

Note

The pattern name comes from "a prop that renders" — not from React's render() method on class components. Any function prop that returns UI qualifies.

Back to index


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

tsx
'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>
}
SmellImpact
Identical useEffect in Car and BikeBug fixes must land twice
Logic mixed with emoji presentationHard to unit-test coordinates without DOM
Adding a third icon copies a third timeMaintenance cost scales linearly

Warning

Duplicated side-effect logic is a code smell — extract once, render many. Render props (or hooks) centralize the subscription.

Back to index


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.

tsx
'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.

tsx
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>
ApproachWhen to prefer
render propNeed static children alongside dynamic UI; multiple render slots (renderHeader, renderBody)
children functionSingle injection point; reads like a scoped template

Tip

Support both only if your library API requires backward compatibility. For new components, pick one style — dual APIs confuse consumers.

Back to index


4. Example: mouse tracker

Extract shared tracking once; Car and Bike only differ in presentation.

tsx
'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)}</>
}
tsx
// 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>

Performance

One MouseTracker instance = one listener. Mounting Car and Bike with separate trackers registers two listeners — lift a single tracker or use a shared hook/context if both mount together.

Back to index


5. Example: data fetching

Render props expose loading, error, and data without prescribing table vs card layout.

tsx
'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)}</>
}
tsx
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>

Note

For production apps, TanStack Query or SWR replace hand-rolled fetch render props — but the shape (state → UI function) remains the same mental model.

Back to index


6. Use cases

Use caseWhy render props fit
Sharing logicMouse position, window size, geolocation, media queries
Data fetching (legacy)Pre-hooks libraries (react-request, older Apollo patterns)
Reusable component librariesConsumer controls markup inside a behavior shell
Legacy codebasesClass components and pre-2018 patterns still in production
Org modernization constraintsIncremental migration when hooks adoption is phased
tsx
// Window width — consumer picks breakpoint UI
<WindowSize>{(width) => (width < 768 ? <MobileNav /> : <DesktopNav />)}</WindowSize>

Tip

Reach for render props in libraries where you cannot predict consumer markup. In application code, a custom hook (useWindowSize) plus local JSX is usually simpler.

Back to index


7. Practice task: Toggle

Build a Toggle that owns isOpen state and exposes toggle — the consumer decides switch vs button vs accordion trigger.

tsx
'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

tsx
<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

tsx
<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

tsx
// 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 })}</>
}

Note

The practice task mirrors real libraries: one state machine, infinite UI skins. Compare with Compound Components where subs share context instead of a function prop.

Back to index


8. Pitfalls & limitations

PitfallProblemMitigation
Inline functions in renderNew function reference every parent render → child re-rendersExtract render callback; useCallback in consumer
Nested render props"Callback hell" in JSX — hard to readExtract sub-component or switch to hooks
DevTools noiseAnonymous function components stack deeplyName render callbacks: function UserList(state) { … }
Mixing with HOCswithX(withY(<RenderProp />)) — wrapper hellPick one abstraction per feature
TestingMust mount wrapper to test UITest logic via hook extraction; snapshot render prop output

Nested render props — anti-pattern

tsx
// ❌ 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>
tsx
// ✅ 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} />
}

Warning

Avoid inline arrow functions as render props inside large list renders — each row creates a new function and can defeat memo on child components.

Warning

Nested render props are a maintenance hazard. At two or more levels, refactor shared logic into custom hooks and keep JSX flat.

Back to index


9. Modern alternatives

Render props solved logic reuse before hooks. Today, three patterns compete:

PatternMechanismStatus
Custom hooksuseMousePosition() — call in any component✅ Standard for app code
Render propsFunction prop returns JSX✅ Libraries, legacy, max UI flexibility
HOCswithAuth(Component) wraps exports⚠️ Legacy — ref forwarding pain
Compound componentsContext + dot notation✅ Design systems — structure over function props

Same Toggle — hook version

tsx
'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

tsx
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>
}

Interview Answer

"Render props vs custom hooks — when do you still use render props?"

Use hooks when any component in your app can call the logic directly. Use render props when you ship a library component that must wrap children or own DOM structure (portal shell, measurement container) and consumers inject UI via a function. In interviews, show you know hooks replaced most app-level render props — but the separation-of-concerns idea persists.

Tip

Production checklist

  • Extract duplicated effects into one render-prop provider or hook
  • Prefer children function or render — not both without reason
  • Name render callbacks for DevTools clarity
  • Flatten nested render props → hooks
  • Libraries: render props or compound components; apps: hooks first
  • For fetch state today, prefer TanStack Query over hand-rolled render props

Back to index


Summary

IdeaRender propsCustom hooks (modern)
Logic locationInside wrapper componentuseX() in any component
UI controlFunction returns JSXLocal JSX in caller
Best forLibraries, legacy, flexible injectionApplication 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.

Share this article

XLinkedInFacebook
Kazi Rahamatullah

Written by

Kazi Rahamatullah

FullStack Developer

X / TwitterGitHubLinkedIn

Subscribe to my newsletter

Stay up to date and get notified when I share new contents.

No spam ever, unsubscribe anytime