React Design Patterns — Compound Components, HOCs, and Render Props

Sanjeev SharmaSanjeev Sharma
5 min read

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.Tab only works inside Tabs
  • 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading