React Design Patterns — Complete Guide
Master React design patterns from Container/Presenter to Suspense boundaries — controlled forms, compound components, HOCs, custom hooks, Context, state reducers, Pub/Sub, performance, slots, facades, and error handling with production examples.
Introduction
React gives you components and hooks — but scaling a codebase requires patterns that keep logic readable, APIs flexible, and teams aligned. This guide maps a practical 15-day learning roadmap (grouped into three phases) covering every major React design pattern from fundamentals to architecture.
You will learn:
- What React is — declarative UI, composition, and the ecosystem
- Why patterns matter — maintainability, extendability, and avoiding code smell
- How components communicate — state vs props
- 15 production patterns — from Container/Presenter to Suspense fallback boundaries
Each pattern includes copy-paste examples, when to use it, and callouts for common pitfalls.
Quick index
1. What is React?
Before patterns, align on what React optimizes for.
Declarative — describe what, not how
You declare the UI for a given state; React reconciles the DOM when state changes.
function Greeting({ name }: { name: string }) {
return <h1>Hello, {name}</h1>
}
// State change → React updates the DOM automatically
// You never call document.createElement manually| Imperative (vanilla JS) | Declarative (React) |
|---|---|
| Find node, mutate text | setState → re-render |
| Manual event wiring | Props + event handlers |
| Easy to desync UI and data | UI is a function of state |
Component-based — single-responsibility units
Each component owns a slice of UI, optional business logic, and styling (often via CSS modules or Tailwind). Components compose into hierarchies.
function App() {
return (
<Layout>
<Sidebar />
<Main>
<PostList posts={posts} />
</Main>
</Layout>
)
}JSX merges markup, logic, and style concerns in one file — colocation beats scattering templates, scripts, and styles across three files when the unit of change is a feature slice.
UI library — not a full framework
React handles the view layer. Routing, data fetching, and forms typically come from the ecosystem (React Router, TanStack Query, React Hook Form) or meta-frameworks (Next.js, Remix).
2. Core motivation
Patterns exist to solve recurring structural problems. The goals from the roadmap:
| Goal | What it means in practice |
|---|---|
| Clean code | One obvious place for fetch logic, form state, and UI markup |
| Maintainability | New engineers find behavior without spelunking |
| Extendability | Add a tab, filter, or auth layer without rewriting consumers |
| Avoid code smell | No 400-line components, prop drilling ten levels deep |
| Best practice standards | Team-wide vocabulary — "use a compound component here" |
3. Component communication
React data flow is top-down. Two primitives cover most cases:
Props — passing data down
Props are read-only inputs from parent to child.
function Avatar({ src, alt, size = 40 }: { src: string; alt: string; size?: number }) {
return <img src={src} alt={alt} width={size} height={size} className="rounded-full" />
}
function UserCard({ user }: { user: { name: string; avatar: string } }) {
return (
<article>
<Avatar src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
</article>
)
}State — private component data
State is owned by the component that declares it (or lifted to the nearest common ancestor).
'use client'
import { useState } from 'react'
function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>
}Lifting state up — when siblings need the same data, move state to their parent and pass callbacks down.
function SearchPage() {
const [query, setQuery] = useState('')
return (
<>
<SearchInput value={query} onChange={setQuery} />
<ResultsList query={query} />
</>
)
}Phase 1 — Initial patterns (Days 1–5)
4. Container / Presenter pattern
Split data logic from UI. The container fetches and owns state; the presenter renders props only.
// containers/UserListContainer.tsx
'use client'
import { useEffect, useState } from 'react'
import { UserListPresenter } from './UserListPresenter'
type User = { id: string; name: string }
export function UserListContainer() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/users')
.then((r) => r.json())
.then(setUsers)
.finally(() => setLoading(false))
}, [])
return <UserListPresenter users={users} loading={loading} />
}// UserListPresenter.tsx — pure UI, easy to snapshot test
type Props = { users: { id: string; name: string }[]; loading: boolean }
export function UserListPresenter({ users, loading }: Props) {
if (loading) return <p>Loading…</p>
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
)
}5. Controlled vs uncontrolled components
Forms are the classic battleground.
Controlled — React owns the value
'use client'
import { useState } from 'react'
function LoginForm() {
const [email, setEmail] = useState('')
return <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" />
}Every keystroke flows through state → predictable validation and reset.
Uncontrolled — the DOM owns the value
'use client'
import { useRef } from 'react'
function LoginFormUncontrolled() {
const emailRef = useRef<HTMLInputElement>(null)
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
console.log(emailRef.current?.value)
}
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} defaultValue="" name="email" />
<button type="submit">Sign in</button>
</form>
)
}| Approach | Best for |
|---|---|
| Controlled | Live validation, dependent fields, instant UI feedback |
| Uncontrolled | Simple forms, file inputs, integrating non-React libs |
6. Compound components
Share implicit state between a family of components via Context — consumers compose flexible markup without prop explosion.
'use client'
import { createContext, useContext, useState, type ReactNode } from 'react'
type TabsContextValue = {
active: string
setActive: (id: string) => void
}
const TabsContext = createContext<TabsContextValue | null>(null)
function useTabs() {
const ctx = useContext(TabsContext)
if (!ctx) throw new Error('Tabs compound components must be used within <Tabs>')
return ctx
}
function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) {
const [active, setActive] = useState(defaultTab)
return (
<TabsContext.Provider value={{ active, setActive }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
)
}
function TabList({ children }: { children: ReactNode }) {
return <div role="tablist">{children}</div>
}
function Tab({ id, children }: { id: string; children: ReactNode }) {
const { active, setActive } = useTabs()
return (
<button role="tab" aria-selected={active === id} onClick={() => setActive(id)}>
{children}
</button>
)
}
function TabPanel({ id, children }: { id: string; children: ReactNode }) {
const { active } = useTabs()
if (active !== id) return null
return <div role="tabpanel">{children}</div>
}
Tabs.List = TabList
Tabs.Tab = Tab
Tabs.Panel = TabPanel
// Usage — flexible layout, shared state
export function SettingsPage() {
return (
<Tabs defaultTab="profile">
<Tabs.List>
<Tabs.Tab id="profile">Profile</Tabs.Tab>
<Tabs.Tab id="billing">Billing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="profile">Profile form…</Tabs.Panel>
<Tabs.Panel id="billing">Billing table…</Tabs.Panel>
</Tabs>
)
}7. Render props
Pass a function as a child (or prop) that receives shared state from the parent component.
'use client'
import { useEffect, useState, type ReactNode } from 'react'
type MousePosition = { x: number; y: number }
function MouseTracker({ children }: { children: (pos: MousePosition) => ReactNode }) {
const [pos, setPos] = useState<MousePosition>({ 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)}</>
}
// Usage
export function CursorBadge() {
return (
<MouseTracker>
{(pos) => (
<span className="fixed top-4 right-4 rounded bg-black px-2 py-1 text-white">
{pos.x}, {pos.y}
</span>
)}
</MouseTracker>
)
}8. Higher-order functions & components (HOCs)
A function that takes a component and returns an enhanced component — built on the same factory pattern as JavaScript HOFs (withLogging(fn) → withAuth(Component)).
'use client'
import { ComponentType, useEffect } from 'react'
function withAuth<P extends object>(Wrapped: ComponentType<P>) {
return function Authenticated(props: P) {
const session = getSession() // your auth helper
if (!session) {
return <p>Please sign in</p>
}
return <Wrapped {...props} />
}
}
function withPageView<P extends object>(pageName: string, Wrapped: ComponentType<P>) {
return function Tracked(props: P) {
useEffect(() => {
analytics.track('page_view', { page: pageName })
}, [])
return <Wrapped {...props} />
}
}
function Dashboard() {
return <h1>Dashboard</h1>
}
export const ProtectedDashboard = withAuth(withPageView('dashboard', Dashboard))Phase 2 — Advanced logic (Days 6–10)
9. Custom hooks
Encapsulate reusable stateful logic — the modern replacement for many HOCs and render props.
'use client'
import { useCallback, useEffect, useState } from 'react'
type UseFetchResult<T> = {
data: T | null
loading: boolean
error: Error | null
refetch: () => void
}
export function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const refetch = useCallback(() => {
setLoading(true)
setError(null)
fetch(url)
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json()
})
.then(setData)
.catch(setError)
.finally(() => setLoading(false))
}, [url])
useEffect(() => {
refetch()
}, [refetch])
return { data, loading, error, refetch }
}
// Usage — logic reused across pages
function ProductPage({ id }: { id: string }) {
const { data, loading, error } = useFetch<{ name: string; price: number }>(`/api/products/${id}`)
if (loading) return <p>Loading…</p>
if (error) return <p>Error: {error.message}</p>
return (
<h1>
{data?.name} — ${data?.price}
</h1>
)
}10. Provider pattern (Context API)
Avoid prop drilling by publishing shared values to a subtree.
'use client'
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
type Theme = 'light' | 'dark'
type ThemeContextValue = {
theme: Theme
toggle: () => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light')
const value = useMemo(
() => ({
theme,
toggle: () => setTheme((t) => (t === 'light' ? 'dark' : 'light')),
}),
[theme],
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
return ctx
}
// Deep child — no prop drilling
function ThemeToggle() {
const { theme, toggle } = useTheme()
return <button onClick={toggle}>Switch to {theme === 'light' ? 'dark' : 'light'} mode</button>
}11. State reducer pattern
Move complex transition logic out of components into a pure reducer — same idea as Redux, local to one feature.
'use client'
import { useReducer } from 'react'
type CartItem = { id: string; name: string; qty: number }
type CartState = { items: CartItem[] }
type CartAction =
| { type: 'ADD'; item: Omit<CartItem, 'qty'> }
| { type: 'REMOVE'; id: string }
| { type: 'SET_QTY'; id: string; qty: number }
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD': {
const existing = state.items.find((i) => i.id === action.item.id)
if (existing) {
return {
items: state.items.map((i) => (i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i)),
}
}
return { items: [...state.items, { ...action.item, qty: 1 }] }
}
case 'REMOVE':
return { items: state.items.filter((i) => i.id !== action.id) }
case 'SET_QTY':
return {
items: state.items.map((i) => (i.id === action.id ? { ...i, qty: action.qty } : i)),
}
default:
return state
}
}
export function CartProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] })
// expose dispatch via context…
return <>{children}</>
}12. Pub/Sub (Observer pattern)
Decouple publishers from subscribers — useful for cross-tree events without Context (modals, toasts, analytics buses).
type Listener<T> = (payload: T) => void
class EventBus<T extends Record<string, unknown>> {
private listeners: Partial<{ [K in keyof T]: Set<Listener<T[K]>> }> = {}
subscribe<K extends keyof T>(event: K, listener: Listener<T[K]>) {
if (!this.listeners[event]) this.listeners[event] = new Set()
this.listeners[event]!.add(listener as Listener<T[keyof T]>)
return () => this.listeners[event]!.delete(listener as Listener<T[keyof T]>)
}
publish<K extends keyof T>(event: K, payload: T[K]) {
this.listeners[event]?.forEach((fn) => fn(payload))
}
}
// Typed app events
type AppEvents = {
'cart:added': { productId: string }
'toast:show': { message: string; variant: 'success' | 'error' }
}
export const appEvents = new EventBus<AppEvents>()
// Publisher — product page
function AddToCartButton({ productId }: { productId: string }) {
return <button onClick={() => appEvents.publish('cart:added', { productId })}>Add to cart</button>
}
// Subscriber — toast host (mount once in layout)
import { useEffect } from 'react'
function ToastHost() {
useEffect(() => {
return appEvents.subscribe('toast:show', ({ message }) => {
// show toast UI…
console.log(message)
})
}, [])
return null
}13. Performance patterns
Patterns that keep renders cheap at scale.
Memoization
import { memo, useCallback, useMemo } from 'react'
const ExpensiveRow = memo(function ExpensiveRow({ label, value }: { label: string; value: number }) {
return (
<tr>
<td>{label}</td>
<td>{value}</td>
</tr>
)
})
function DataTable({ rows }: { rows: { label: string; value: number }[] }) {
const total = useMemo(() => rows.reduce((sum, r) => sum + r.value, 0), [rows])
const handleExport = useCallback(() => {
downloadCsv(rows)
}, [rows])
return (
<>
<p>Total: {total}</p>
<button onClick={handleExport}>Export</button>
<table>
<tbody>
{rows.map((r) => (
<ExpensiveRow key={r.label} {...r} />
))}
</tbody>
</table>
</>
)
}Virtualization — render only visible rows
import { useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualList({ items }: { items: string[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
})
return (
<div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((row) => (
<div
key={row.key}
style={{
position: 'absolute',
top: row.start,
height: row.size,
}}>
{items[row.index]}
</div>
))}
</div>
</div>
)
}Phase 3 — Architecture (Days 11–15)
14. Slot pattern
Named insertion points — common in design systems (Radix, shadcn/ui) where consumers pass children or specific sub-slots.
type CardProps = {
children: React.ReactNode
}
function Card({ children }: CardProps) {
return <div className="neo-border rounded-lg p-4">{children}</div>
}
function CardHeader({ children }: { children: React.ReactNode }) {
return <header className="mb-2 font-bold">{children}</header>
}
function CardBody({ children }: { children: React.ReactNode }) {
return <div className="text-sm">{children}</div>
}
function CardFooter({ children }: { children: React.ReactNode }) {
return <footer className="mt-4 flex gap-2">{children}</footer>
}
Card.Header = CardHeader
Card.Body = CardBody
Card.Footer = CardFooter
// Usage — layout slots, no boolean props like showFooter
export function ProductCard() {
return (
<Card>
<Card.Header>Wireless Headphones</Card.Header>
<Card.Body>$199 — noise cancelling, 30h battery</Card.Body>
<Card.Footer>
<button>Add to cart</button>
<button>Compare</button>
</Card.Footer>
</Card>
)
}15. Hooks factory (Strategy pattern)
Return different hook implementations from a factory — swap strategies without changing consumers.
type StorageStrategy = {
getItem: (key: string) => string | null
setItem: (key: string, value: string) => void
}
const localStorageStrategy: StorageStrategy = {
getItem: (key) => (typeof window !== 'undefined' ? localStorage.getItem(key) : null),
setItem: (key, value) => localStorage.setItem(key, value),
}
const sessionStorageStrategy: StorageStrategy = {
getItem: (key) => (typeof window !== 'undefined' ? sessionStorage.getItem(key) : null),
setItem: (key, value) => sessionStorage.setItem(key, value),
}
function createUsePersistedState(strategy: StorageStrategy) {
return function usePersistedState<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
const raw = strategy.getItem(key)
return raw ? (JSON.parse(raw) as T) : initial
})
useEffect(() => {
strategy.setItem(key, JSON.stringify(value))
}, [key, value])
return [value, setValue] as const
}
}
export const useLocalStorage = createUsePersistedState(localStorageStrategy)
export const useSessionStorage = createUsePersistedState(sessionStorageStrategy)16. Facade pattern
Hide a complex subsystem behind a simple React-facing API.
// lib/payments/facade.ts — wraps Stripe + tax + analytics
export async function checkoutCart(cartId: string, userId: string) {
const cart = await db.cart.findUnique({ where: { id: cartId } })
if (!cart) throw new Error('Cart not found')
const tax = await taxService.calculate(cart)
const intent = await stripe.paymentIntents.create({
amount: cart.subtotal + tax,
currency: 'usd',
metadata: { cartId, userId },
})
analytics.track('checkout_started', { cartId, userId })
return { clientSecret: intent.client_secret }
}
// components/CheckoutButton.tsx — one call site
;('use client')
export function CheckoutButton({ cartId }: { cartId: string }) {
async function handleCheckout() {
const { clientSecret } = await checkoutCart(cartId, currentUserId)
await stripe.confirmPayment({ clientSecret })
}
return <button onClick={handleCheckout}>Pay now</button>
}17. Error boundaries
Catch render errors in child trees and show fallback UI — class components (or react-error-boundary) only; hooks cannot catch sibling errors.
'use client'
import { Component, type ErrorInfo, type ReactNode } from 'react'
type Props = { children: ReactNode; fallback?: ReactNode }
type State = { hasError: boolean }
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false }
static getDerivedStateFromError(): State {
return { hasError: true }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('Boundary caught:', error, info.componentStack)
// report to Sentry, etc.
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? <p>Something went wrong.</p>
}
return this.props.children
}
}
// Usage — isolate risky widgets
export function DashboardPage() {
return (
<div>
<ErrorBoundary fallback={<p>Chart failed to load.</p>}>
<RevenueChart />
</ErrorBoundary>
<ErrorBoundary fallback={<p>Feed unavailable.</p>}>
<ActivityFeed />
</ErrorBoundary>
</div>
)
}18. Suspense fallback boundaries
Declare loading UI at the boundary where async children suspend — pairs with React 18+ streaming and lazy().
import { Suspense, lazy } from 'react'
const AnalyticsPanel = lazy(() => import('./AnalyticsPanel'))
function PanelSkeleton() {
return <div className="h-48 animate-pulse rounded bg-neutral-200" />
}
export function ReportsPage() {
return (
<section>
<h1>Reports</h1>
{/* Static shell renders immediately */}
<Suspense fallback={<PanelSkeleton />}>
<AnalyticsPanel />
</Suspense>
<Suspense fallback={<p>Loading export history…</p>}>
<ExportHistory />
</Suspense>
</section>
)
}With Next.js App Router, loading.tsx is a route-level Suspense boundary; nested <Suspense> splits streaming inside a page.
// app/reports/loading.tsx
export default function Loading() {
return <PanelSkeleton />
}Summary
| Phase | Patterns | Primary win |
|---|---|---|
| Days 1–5 | Container/Presenter, controlled forms, compound, render props, HOC | Flexible UI APIs, separation of concerns |
| Days 6–10 | Custom hooks, Provider, reducer, Pub/Sub, performance | Reusable logic, scalable state |
| Days 11–15 | Slots, hook factory, facade, error boundary, Suspense | Architecture and resilience |
React design patterns evolve — HOCs and render props gave way to hooks — but the motivations stay the same: clean boundaries, composable APIs, and predictable data flow.
Next reads: TanStack Query for server-state patterns, MERN React Essentials for component fundamentals, and Next.js Caching & Rendering for streaming with Suspense at the framework level.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime