Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
ReactDesign PatternsArchitectureCustom HooksComponent Patterns

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.

Jul 3, 202622 min read

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:

  1. What React is — declarative UI, composition, and the ecosystem
  2. Why patterns matter — maintainability, extendability, and avoiding code smell
  3. How components communicate — state vs props
  4. 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.

Note

Design patterns are tools, not rules. A small feature does not need a compound component API — reach for patterns when complexity or reuse demands structure.

Quick index

#Section
1What is React?
2Core motivation
3Component communication
4Container / Presenter
5Controlled vs uncontrolled
6Compound components
7Render props
8Higher-order components (HOCs)
9Custom hooks
10Provider pattern (Context)
11State reducer pattern
12Pub/Sub (Observer)
13Performance patterns
14Slot pattern
15Hooks factory (Strategy)
16Facade pattern
17Error boundaries
18Suspense fallback boundaries

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.

tsx
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 textsetState → re-render
Manual event wiringProps + event handlers
Easy to desync UI and dataUI 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.

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

Note

React is a UI library, not batteries-included. Patterns like Provider, Facade, and custom hooks exist partly because React deliberately stays unopinionated about global state and data layers.

Back to index


2. Core motivation

Patterns exist to solve recurring structural problems. The goals from the roadmap:

GoalWhat it means in practice
Clean codeOne obvious place for fetch logic, form state, and UI markup
MaintainabilityNew engineers find behavior without spelunking
ExtendabilityAdd a tab, filter, or auth layer without rewriting consumers
Avoid code smellNo 400-line components, prop drilling ten levels deep
Best practice standardsTeam-wide vocabulary — "use a compound component here"

Warning

Code smell signals: components that fetch, validate, animate, and render tables in one file; props renamed and passed through five layers; duplicate useEffect blocks across pages. Patterns refactor these smells into named structures.

Tip

Start a feature without a pattern. Introduce one when you copy-paste the same hook logic twice, or when a component's props exceed ~8 meaningful fields.

Back to index


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.

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

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

tsx
function SearchPage() {
  const [query, setQuery] = useState('')
 
  return (
    <>
      <SearchInput value={query} onChange={setQuery} />
      <ResultsList query={query} />
    </>
  )
}

Note

Props vs state: props are external configuration; state is internal memory. Context, reducers, and Pub/Sub extend this model when prop drilling becomes painful — covered in sections 10–12.

Back to index


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.

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

Tip

In modern React, a custom hook (useUsers) often replaces the container class/file — the presenter stays the same. Container/Presenter is still the mental model: separate logic from markup.

Deep dive: Container-Presenter Pattern — code smells, refactoring steps, use cases, and pitfalls.

Back to index


5. Controlled vs uncontrolled components

Forms are the classic battleground.

Controlled — React owns the value

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

tsx
'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>
  )
}
ApproachBest for
ControlledLive validation, dependent fields, instant UI feedback
UncontrolledSimple forms, file inputs, integrating non-React libs

Note

React 19+ useActionState and Server Actions blur the line — you can post uncontrolled form data to the server while keeping client fields uncontrolled for performance on large forms.

Deep dive: Controlled vs Uncontrolled Components — state vs refs, FormData, mixing patterns, pitfalls, and React 19 form hooks. For multi-field apps, see Clean React Forms Pattern.

Back to index


6. Compound components

Share implicit state between a family of components via Context — consumers compose flexible markup without prop explosion.

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

Performance

Compound components avoid re-rendering unrelated tabs if you memoize context value with useMemo when the provider wraps large subtrees.

Note

Deep dive: Compound Components Pattern — prop soup, Modal/Accordion examples, Card/Tabs tasks, and pitfalls.

Back to index


7. Render props

Pass a function as a child (or prop) that receives shared state from the parent component.

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

Tip

Render props vs custom hooks: hooks replaced most render-prop use cases (useMousePosition()). Render props remain useful when a library component owns DOM structure you cannot hook into (legacy class components, third-party widgets).

Deep dive: Render Props Pattern — logic duplication, Toggle task, pitfalls, and hook migration.

Back to index


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)).

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

Warning

HOC pitfalls: wrapper hell in DevTools, ref forwarding requires forwardRef, and static properties get lost unless you hoist them. Prefer hooks or composition for new code; HOCs remain common in older codebases and libraries (connect from Redux).

Deep dive: Higher-Order Functions & HOCs — HOF foundations, withDataFetching, admin permissions, and hook migration.

Back to index


Phase 2 — Advanced logic (Days 6–10)

9. Custom hooks

Encapsulate reusable stateful logic — the modern replacement for many HOCs and render props.

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

Note

Custom hooks must start with use and obey the Rules of Hooks. Extract when the same useState + useEffect combo appears twice.

Deep dive: Optimistic UI Pattern — React 19 useOptimistic, startTransition, likes/chat/cart examples, rollback on failure, and anti-patterns.

Back to index


10. Provider pattern (Context API)

Avoid prop drilling by publishing shared values to a subtree.

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

Performance

Context triggers re-renders on every consumer when the value reference changes. Split contexts (theme vs user session), memoize values, or use selectors (Zustand, Jotai) for high-frequency updates.

Note

Deep dive: Provider Pattern — definition, Context boundaries, Theme and Brand examples, nested providers, React 19 use API, auth/i18n/cart use cases, and re-render pitfalls.

Back to index


11. State reducer pattern

Move complex transition logic out of components into a pure reducer — same idea as Redux, local to one feature.

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

Tip

Use useReducer when the next state depends on the previous state in multiple ways (wizard steps, multi-field forms, undo stacks). Stick with useState for a single boolean or string.

Note

Deep dive: State Reducer Pattern — core concepts, useReducer anatomy, inversion of control, Toggle and form validation examples, Context combos, undo/redo, and pitfalls.

Back to index


12. Pub/Sub (Observer pattern)

Decouple publishers from subscribers — useful for cross-tree events without Context (modals, toasts, analytics buses).

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

Warning

Pub/Sub is hard to trace in DevTools. Prefer Context or a state library for application state; reserve event buses for fire-and-forget side effects (analytics, toasts, cross-micro-frontend signals).

Note

Deep dive: Pub-Sub vs Observer Patterns — Subject vs Event Bus, useEventBus hook, BroadcastChannel cross-tab sync, Context comparison, toast/analytics use cases, and anti-patterns.

Back to index


13. Performance patterns

Patterns that keep renders cheap at scale.

Memoization

tsx
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

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

Performance

Do not memo everything. Profile first (React DevTools Profiler). Memo helps when props are stable and child render cost is high — not for trivial <span> nodes.

Note

Deep dive: React Performance Guide (Part 1) — re-render triggers, memoization, derived state, debouncing, throttling, and Part 2 preview (virtualization, Context splits, React Compiler).

Back to index


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.

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

Note

Slots overlap with compound components — slots emphasize layout regions; compound components emphasize shared implicit state (tabs, accordion). Many libraries combine both.

Deep dive: Slot Pattern — default children, named slots, slot maps, vs compound components, Modal/Card/Layout use cases, responsive toolbar and toast tasks.

Back to index


15. Hooks factory (Strategy pattern)

Return different hook implementations from a factory — swap strategies without changing consumers.

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

Tip

Use hook factories for test doubles — pass a mock storage strategy in unit tests without touching localStorage.

Note

Deep dive: Strategy Pattern & Hook Factory — strategy registry, Context providers, OTP/SSO/Magic Link auth, REST/GraphQL fetch strategies, Rules of Hooks, and anti-patterns.

Back to index


16. Facade pattern

Hide a complex subsystem behind a simple React-facing API.

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

Note

Facades belong in lib/ or services/, not inside JSX files. Components stay thin; the facade owns orchestration and error mapping.

Deep dive: Facade Pattern — hotel reception analogy, facade hooks vs normal hooks, Profile/Cart/Notification examples, refactoring benefits, and anti-patterns.

Back to index


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.

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

Warning

Error boundaries do not catch event handler errors, async errors, or SSR failures — use try/catch in handlers and Next.js error.tsx for route-level recovery.

Note

Deep dive: Error Boundary Pattern — definition, default React behavior, class component implementation, granular placement, retry with key reset, logging, and limits.

Back to index


18. Suspense fallback boundaries

Declare loading UI at the boundary where async children suspend — pairs with React 18+ streaming and lazy().

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

tsx
// app/reports/loading.tsx
export default function Loading() {
  return <PanelSkeleton />
}

Performance

Granular Suspense boundaries improve perceived performance — ship the shell and hero content first, stream heavy charts and tables after. Avoid one giant boundary that blocks the entire page.

Note

Deep dive: Suspense Pattern — fetch-on-render problems, use() API, resources architecture, Error Boundary integration, Account dashboard example, and Next.js streaming.

Tip

Production checklist

  • Separate logic (hooks/containers) from UI (presenters)
  • Prefer custom hooks over HOCs for new cross-cutting logic
  • Use compound components / slots for flexible design-system APIs
  • Context for theme/auth; reducers for complex local state; Pub/Sub sparingly
  • Profile before memo; virtualize long lists
  • Error boundaries per widget; Suspense per async region
  • Facades for multi-service orchestration

Interview Answer

"When would you choose compound components over render props?"

Compound components when consumers need to ** rearrange markup** (tabs, accordion, menu) while sharing hidden state — the API stays declarative (<Tabs.Tab>). Render props when you need maximum flexibility over what gets rendered from shared logic, or when integrating with code you cannot refactor to hooks. In 2026 codebases, custom hooks cover most render-prop cases; compound components remain the standard for design systems.

Back to index


Summary

PhasePatternsPrimary win
Days 1–5Container/Presenter, controlled forms, compound, render props, HOCFlexible UI APIs, separation of concerns
Days 6–10Custom hooks, Provider, reducer, Pub/Sub, performanceReusable logic, scalable state
Days 11–15Slots, hook factory, facade, error boundary, SuspenseArchitecture 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.

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