React Compound Components and Render Props Patterns
Compound components and render props are two of React's most powerful component design patterns. Compound components let you build APIs where parent and child components share implicit state — the way HTML's <select> and <option> work together. Render props invert control by letting callers decide what gets rendered while the component handles logic. Together these patterns produce flexible, reusable UI libraries that are easy to compose without prop drilling.
Table of Contents
Compound Components with Context
The modern approach to compound components uses React Context to share state between parent and sub-components without requiring props to be threaded through the tree. The parent owns the state; sub-components consume it via context. Users assemble the pieces in whatever order and nesting they need.
import { createContext, useContext, useState } from 'react'
// Context holds shared state
const ToggleContext = createContext(null)
function useToggle() {
const ctx = useContext(ToggleContext)
if (!ctx) throw new Error('Toggle subcomponents must be used inside <Toggle>')
return ctx
}
// Parent component — owns state, provides context
function Toggle({ children, defaultOn = false, onChange }) {
const [on, setOn] = useState(defaultOn)
function toggle() {
const next = !on
setOn(next)
onChange?.(next)
}
return (
<ToggleContext.Provider value={{ on, toggle }}>
{children}
</ToggleContext.Provider>
)
}
// Sub-components — consume context
Toggle.Button = function ToggleButton({ children }) {
const { on, toggle } = useToggle()
return (
<button
onClick={toggle}
aria-pressed={on}
style={{ background: on ? '#6366f1' : '#374151' }}
>
{children ?? (on ? 'ON' : 'OFF')}
</button>
)
}
Toggle.On = function ToggleOn({ children }) {
const { on } = useToggle()
return on ? <>{children}</> : null
}
Toggle.Off = function ToggleOff({ children }) {
const { on } = useToggle()
return on ? null : <>{children}</>
}
// Usage — consumer controls structure
function App() {
return (
<Toggle onChange={v => console.log('toggled:', v)}>
<Toggle.Off><p>The feature is off.</p></Toggle.Off>
<Toggle.On><p>The feature is on!</p></Toggle.On>
<Toggle.Button />
</Toggle>
)
}
Toggle.Button) keeps the API discoverable and avoids polluting your module's export namespace.
Accordion Example
An accordion is a classic compound component. The parent tracks which panel is open; each Item knows its own identity and queries context to decide whether to render its content.
const AccordionContext = createContext(null)
const AccordionItemContext = createContext(null)
function Accordion({ children, defaultOpen = null, allowMultiple = false }) {
const [openItems, setOpenItems] = useState(
defaultOpen !== null ? [defaultOpen] : []
)
function toggle(id) {
setOpenItems(prev => {
if (prev.includes(id)) return prev.filter(i => i !== id)
return allowMultiple ? [...prev, id] : [id]
})
}
return (
<AccordionContext.Provider value={{ openItems, toggle }}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
)
}
Accordion.Item = function AccordionItem({ id, children }) {
const ctx = useContext(AccordionContext)
if (!ctx) throw new Error('Accordion.Item must be inside Accordion')
return (
<AccordionItemContext.Provider value={{ id, isOpen: ctx.openItems.includes(id), toggle: ctx.toggle }}>
<div className="accordion-item">{children}</div>
</AccordionItemContext.Provider>
)
}
Accordion.Trigger = function AccordionTrigger({ children }) {
const { id, isOpen, toggle } = useContext(AccordionItemContext)
return (
<button
className="accordion-trigger"
onClick={() => toggle(id)}
aria-expanded={isOpen}
>
{children}
<span style={{ marginLeft: 'auto' }}>{isOpen ? '▲' : '▼'}</span>
</button>
)
}
Accordion.Content = function AccordionContent({ children }) {
const { isOpen } = useContext(AccordionItemContext)
return isOpen ? <div className="accordion-content">{children}</div> : null
}
// Usage
function FAQ() {
return (
<Accordion defaultOpen="q1">
<Accordion.Item id="q1">
<Accordion.Trigger>What is React?</Accordion.Trigger>
<Accordion.Content>A JavaScript library for building UIs.</Accordion.Content>
</Accordion.Item>
<Accordion.Item id="q2">
<Accordion.Trigger>What are hooks?</Accordion.Trigger>
<Accordion.Content>Functions that let you use state in function components.</Accordion.Content>
</Accordion.Item>
</Accordion>
)
}
Tabs Component
A Tabs component built as compound components naturally maps to the WAI-ARIA tab pattern. The parent manages the active index; Tab and Panel sub-components handle rendering and accessibility attributes automatically.
const TabsContext = createContext(null)
function Tabs({ children, defaultIndex = 0 }) {
const [activeIndex, setActiveIndex] = useState(defaultIndex)
return (
<TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
)
}
Tabs.List = function TabList({ children }) {
return (
<div role="tablist" className="tab-list">
{Children.map(children, (child, index) =>
cloneElement(child, { index })
)}
</div>
)
}
Tabs.Tab = function Tab({ children, index }) {
const { activeIndex, setActiveIndex } = useContext(TabsContext)
const isActive = activeIndex === index
return (
<button
role="tab"
aria-selected={isActive}
tabIndex={isActive ? 0 : -1}
onClick={() => setActiveIndex(index)}
className={`tab ${isActive ? 'tab--active' : ''}`}
>
{children}
</button>
)
}
Tabs.Panels = function TabPanels({ children }) {
const { activeIndex } = useContext(TabsContext)
return (
<div className="tab-panels">
{Children.map(children, (child, index) =>
cloneElement(child, { index })
)}
</div>
)
}
Tabs.Panel = function TabPanel({ children, index }) {
const { activeIndex } = useContext(TabsContext)
if (activeIndex !== index) return null
return (
<div role="tabpanel" className="tab-panel">
{children}
</div>
)
}
// Usage
function SettingsPage() {
return (
<Tabs defaultIndex={0}>
<Tabs.List>
<Tabs.Tab>Profile</Tabs.Tab>
<Tabs.Tab>Security</Tabs.Tab>
<Tabs.Tab>Billing</Tabs.Tab>
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel><ProfileSettings /></Tabs.Panel>
<Tabs.Panel><SecuritySettings /></Tabs.Panel>
<Tabs.Panel><BillingSettings /></Tabs.Panel>
</Tabs.Panels>
</Tabs>
)
}
Render Props Pattern
The render props pattern passes a function as a prop (usually named render or children). The component calls this function with its internal state, giving the consumer full control over what gets rendered. This was the primary code-reuse pattern before hooks, and it still has value when you need maximum rendering flexibility.
// Mouse tracker with render prop
function MouseTracker({ render }) {
const [position, setPosition] = useState({ x: 0, y: 0 })
return (
<div
style={{ height: '300px', border: '1px solid #374151' }}
onMouseMove={e => setPosition({ x: e.clientX, y: e.clientY })}
>
{render(position)}
</div>
)
}
// Consumer decides what to render
function App() {
return (
<MouseTracker
render={({ x, y }) => (
<p>Mouse position: {x}, {y}</p>
)}
/>
)
}
// Data fetcher with render prop
function DataFetcher({ url, render }) {
const [state, setState] = useState({ data: null, loading: true, error: null })
useEffect(() => {
fetch(url)
.then(r => r.json())
.then(data => setState({ data, loading: false, error: null }))
.catch(err => setState({ data: null, loading: false, error: err.message }))
}, [url])
return render(state)
}
// Each consumer renders differently
function UserProfile({ userId }) {
return (
<DataFetcher
url={`/api/users/${userId}`}
render={({ data, loading, error }) => {
if (loading) return <Spinner />
if (error) return <ErrorMessage message={error} />
return <UserCard user={data} />
}}
/>
)
}
Children as Function
The most ergonomic form of render props uses children as the function. This reads naturally — the logic goes inside JSX tags the way you'd expect, and no extra prop name is needed.
// Children as function — more idiomatic JSX
function Countdown({ from, children }) {
const [count, setCount] = useState(from)
useEffect(() => {
if (count === 0) return
const id = setTimeout(() => setCount(c => c - 1), 1000)
return () => clearTimeout(id)
}, [count])
return children({ count, isDone: count === 0, reset: () => setCount(from) })
}
// Usage — no render prop name, children is the function
function LaunchPage() {
return (
<Countdown from={10}>
{({ count, isDone, reset }) => (
<div>
{isDone
? <button onClick={reset}>Restart</button>
: <p>Launching in {count}...</p>
}
</div>
)}
</Countdown>
)
}
// Virtualised list with children as function
function VirtualList({ items, itemHeight, visibleCount, children }) {
const [scrollTop, setScrollTop] = useState(0)
const startIndex = Math.floor(scrollTop / itemHeight)
const visibleItems = items.slice(startIndex, startIndex + visibleCount)
return (
<div
style={{ height: visibleCount * itemHeight, overflowY: 'scroll' }}
onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
>
<div style={{ height: items.length * itemHeight, position: 'relative' }}>
{visibleItems.map((item, i) => (
<div
key={startIndex + i}
style={{ position: 'absolute', top: (startIndex + i) * itemHeight, height: itemHeight }}
>
{children(item, startIndex + i)}
</div>
))}
</div>
</div>
)
}
Hooks vs Render Props
Custom hooks have largely replaced render props for logic reuse because hooks compose more cleanly. But render props still win when you need to share rendering logic, not just stateful logic — when the component itself decides how to call the render function, or when you want to avoid the re-render overhead of a wrapper component in hot paths.
// Same logic: custom hook version — simpler for most cases
function useMousePosition() {
const [position, setPosition] = useState({ x: 0, y: 0 })
const ref = useRef(null)
useEffect(() => {
const el = ref.current
if (!el) return
const handler = e => setPosition({ x: e.clientX, y: e.clientY })
el.addEventListener('mousemove', handler)
return () => el.removeEventListener('mousemove', handler)
}, [])
return { position, ref }
}
// Hook usage — no wrapper component
function Tooltip() {
const { position, ref } = useMousePosition()
return (
<div ref={ref}>
<p>x: {position.x}, y: {position.y}</p>
</div>
)
}
// When to still prefer render props:
// 1. When the component controls when render is called (e.g., virtualization)
// 2. When you need to pass refs from the component into the rendered output
// 3. In libraries where consumers can't use hooks (class components)
Flexible Component API Design
The best compound component APIs support multiple usage styles. Accept a simple items prop for quick use, while also allowing sub-components for full control. This is the "prop getter" pattern — the component provides handlers you can spread onto your own elements.
// Prop getter pattern — maximum flexibility
function useCombobox({ items, onSelect }) {
const [isOpen, setIsOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const filtered = items.filter(item =>
item.label.toLowerCase().includes(inputValue.toLowerCase())
)
function getInputProps() {
return {
value: inputValue,
onChange: e => { setInputValue(e.target.value); setIsOpen(true) },
onKeyDown: e => {
if (e.key === 'ArrowDown') setHighlightedIndex(i => Math.min(i + 1, filtered.length - 1))
if (e.key === 'ArrowUp') setHighlightedIndex(i => Math.max(i - 1, 0))
if (e.key === 'Enter' && highlightedIndex >= 0) {
onSelect(filtered[highlightedIndex])
setIsOpen(false)
}
if (e.key === 'Escape') setIsOpen(false)
},
onFocus: () => setIsOpen(true),
}
}
function getItemProps(item, index) {
return {
onClick: () => { onSelect(item); setIsOpen(false) },
onMouseEnter: () => setHighlightedIndex(index),
style: { background: highlightedIndex === index ? '#6366f1' : 'transparent' },
}
}
return { isOpen, filtered, highlightedIndex, getInputProps, getItemProps }
}
// Consumer spreads prop getters onto their own markup
function CountrySelect() {
const { isOpen, filtered, getInputProps, getItemProps } = useCombobox({
items: countries,
onSelect: country => console.log('selected:', country),
})
return (
<div style={{ position: 'relative' }}>
<input {...getInputProps()} placeholder="Search countries..." />
{isOpen && (
<ul className="dropdown">
{filtered.map((item, i) => (
<li key={item.value} {...getItemProps(item, i)}>{item.label}</li>
))}
</ul>
)}
</div>
)
}
TypeScript Compound Components
TypeScript requires explicit typing for sub-components attached as static properties. Use an interface to define the component plus its sub-components, or use a typed namespace export. Both approaches give consumers autocomplete and type-checking on the compound API.
import { createContext, useContext, useState, ReactNode, FC } from 'react'
interface TabsContextValue {
activeIndex: number
setActiveIndex: (index: number) => void
}
const TabsContext = createContext<TabsContextValue | null>(null)
function useTabs() {
const ctx = useContext(TabsContext)
if (!ctx) throw new Error('Must be inside Tabs')
return ctx
}
// Define sub-component types
interface TabsComponent extends FC<{ children: ReactNode; defaultIndex?: number }> {
List: FC<{ children: ReactNode }>
Tab: FC<{ children: ReactNode; index?: number }>
Panel: FC<{ children: ReactNode; index?: number }>
}
const Tabs: TabsComponent = ({ children, defaultIndex = 0 }) => {
const [activeIndex, setActiveIndex] = useState(defaultIndex)
return (
<TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
<div>{children}</div>
</TabsContext.Provider>
)
}
Tabs.List = ({ children }) => <div role="tablist">{children}</div>
Tabs.Tab = ({ children, index = 0 }) => {
const { activeIndex, setActiveIndex } = useTabs()
return (
<button
role="tab"
aria-selected={activeIndex === index}
onClick={() => setActiveIndex(index)}
>
{children}
</button>
)
}
Tabs.Panel = ({ children, index = 0 }) => {
const { activeIndex } = useTabs()
return activeIndex === index ? <div role="tabpanel">{children}</div> : null
}
export default Tabs