React Design Patterns — Compound Components, HOCs, and Render Props
Advertisement
Introduction
Why This Matters
React patterns define how components share behavior and state. Choosing the wrong pattern leads to rigid, hard-to-test components or deeply nested prop-drilling. Understanding these patterns lets you design APIs that are intuitive for consumers and maintainable as requirements evolve.
Compound Components
Compound components are a set of related sub-components that share implicit state via React Context. They mirror HTML patterns like <select> and <option>.
// components/tabs/index.tsx
'use client'
import { createContext, useContext, useState } from 'react'
type TabsContextType = { activeTab: string; setActiveTab: (id: string) => void }
const TabsContext = createContext<TabsContextType | null>(null)
function useTabs() {
const ctx = useContext(TabsContext)
if (!ctx) throw new Error('Tab components must be used inside <Tabs>')
return ctx
}
function Tabs({ defaultTab, children }: { defaultTab: string; children: React.ReactNode }) {
const [activeTab, setActiveTab] = useState(defaultTab)
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="w-full">{children}</div>
</TabsContext.Provider>
)
}
function TabList({ children }: { children: React.ReactNode }) {
return <div role="tablist" className="flex border-b">{children}</div>
}
function Tab({ id, children }: { id: string; children: React.ReactNode }) {
const { activeTab, setActiveTab } = useTabs()
const isActive = activeTab === id
return (
<button
role="tab"
aria-selected={isActive}
onClick={() => setActiveTab(id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
isActive ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{children}
</button>
)
}
function TabPanel({ id, children }: { id: string; children: React.ReactNode }) {
const { activeTab } = useTabs()
if (activeTab !== id) return null
return <div role="tabpanel" className="p-4">{children}</div>
}
// Attach sub-components
Tabs.List = TabList
Tabs.Tab = Tab
Tabs.Panel = TabPanel
export { Tabs }Usage:
<Tabs defaultTab="overview">
<Tabs.List>
<Tabs.Tab id="overview">Overview</Tabs.Tab>
<Tabs.Tab id="analytics">Analytics</Tabs.Tab>
<Tabs.Tab id="settings">Settings</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="overview"><OverviewContent /></Tabs.Panel>
<Tabs.Panel id="analytics"><AnalyticsContent /></Tabs.Panel>
<Tabs.Panel id="settings"><SettingsContent /></Tabs.Panel>
</Tabs>Higher-Order Components (HOCs)
HOCs wrap a component to inject behavior. Use them for cross-cutting concerns like authentication guards, analytics tracking, or feature flags.
// hocs/with-auth.tsx
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
type WithAuthOptions = {
requiredRole?: 'admin' | 'editor' | 'user'
redirectTo?: string
}
export function withAuth<P extends object>(
Component: React.ComponentType<P & { user: User }>,
options: WithAuthOptions = {}
) {
return async function AuthenticatedComponent(props: P) {
const session = await getSession()
if (!session) {
redirect(options.redirectTo ?? '/login')
}
if (options.requiredRole && session.user.role !== options.requiredRole) {
redirect('/403')
}
return <Component {...props} user={session.user} />
}
}
// Usage — works with Next.js Server Components
const AdminDashboard = withAuth(Dashboard, { requiredRole: 'admin' })Render Props
Render props pass rendering logic as a function prop, giving the consumer full control over what gets rendered with the provided data.
// components/data-fetcher.tsx
'use client'
import { useState, useEffect } from 'react'
type DataFetcherProps<T> = {
url: string
children: (state: { data: T | null; loading: boolean; error: string | null }) => React.ReactNode
}
export function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
setLoading(true)
fetch(url)
.then(r => r.json())
.then(setData)
.catch(e => setError(e.message))
.finally(() => setLoading(false))
}, [url])
return <>{children({ data, loading, error })}</>
}
// Usage
<DataFetcher<Post[]> url="/api/posts">
{({ data: posts, loading, error }) => {
if (loading) return <Spinner />
if (error) return <ErrorMessage message={error} />
return <PostList posts={posts ?? []} />
}}
</DataFetcher>Modern Alternative: Custom Hooks
Custom hooks replace render props with a simpler API in most cases:
// hooks/use-fetch.ts
export function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
fetch(url)
.then(r => r.json())
.then(d => { if (!cancelled) setData(d) })
.catch(e => { if (!cancelled) setError(e.message) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [url])
return { data, loading, error }
}
// Usage — cleaner than render props
function PostList() {
const { data: posts, loading, error } = useFetch<Post[]>('/api/posts')
if (loading) return <Spinner />
if (error) return <ErrorMessage message={error} />
return <>{posts?.map(p => <PostCard key={p.id} post={p} />)}</>
}Common Mistakes
- Creating HOCs for concerns that custom hooks handle more cleanly
- Deep-nesting render props ("callback hell") — custom hooks solve this
- Not memoizing context values in compound components, causing unnecessary re-renders of all consumers
- Writing HOCs that break TypeScript typing — always preserve the wrapped component's prop types
Best Practices
- Prefer compound components for UI component families that belong together (Tabs, Accordion, Select)
- Use HOCs for cross-cutting concerns in Server Components (auth guards, feature flags)
- Reach for custom hooks before render props in Client Components — simpler composition
- Document the implicit API contract between compound component sub-components
Key Takeaways
- Compound components share implicit state via Context — perfect for Tabs, Accordion, and Select UI families
- HOCs wrap a component to inject behavior without modifying it — ideal for auth guards and logging
- Render props pass rendering control to the consumer as a function child, enabling flexible composition
- Custom hooks are the modern replacement for render props in Client Components — easier to read and compose
- Compound components must be used together — document that
Tabs.Tabonly works insideTabs - TypeScript discriminated unions make HOC prop injection type-safe without requiring
any - Render prop components and HOCs both exist in popular libraries — recognizing the pattern helps you use those libraries correctly
- None of these patterns is universally better — choose based on whether the concern is UI structure, cross-cutting behavior, or data sharing
Advertisement