---
title: "React Design Patterns: Complete Guide (2026) | TurboDocx"
description: "Master 10 essential React design patterns for 2026 — custom hooks, compound components, server components, and error boundaries with practical examples."
url: "https://www.turbodocx.com/blog/react-design-patterns"
---

# React Design Patterns: The Complete Guide

> Ten battle-tested patterns that make React applications maintainable, scalable, and a joy to work with. From custom hooks to server components, every pattern explained with real code.

By Yacine Kahlerras (Software Engineer, Platform & UX at TurboDocx). Published March 14, 2026. 18 min read. Category: React & Architecture.

Every React codebase eventually hits a wall. Components get too large. Logic gets duplicated. State management becomes a maze. Design patterns are the solution: proven structures that keep your code organized as it grows from a prototype to a production application.

This guide covers the ten patterns that matter most in 2026. Whether you're building a [high-throughput API integration](https://www.turbodocx.com/products/api-and-sdk.md) or a [complex document workflow](https://www.turbodocx.com/developers.md), these patterns will help you write React code that scales without becoming unmaintainable.

| Pattern | Category | When To Use | Complexity |
| --- | --- | --- | --- |
| Custom Hooks | Logic Reuse | Shared stateful logic across components | Low |
| Compound Components | Component API | Flexible, composable UI components | Medium |
| Container/Presentational | Architecture | Separating data from display | Low |
| Render Props | Logic Reuse | Dynamic rendering behavior | Medium |
| State Reducer | State Mgmt | Consumer-controlled state transitions | High |
| Provider Pattern | Data Flow | Cross-tree data sharing | Medium |
| Higher-Order Components | Logic Reuse | Cross-cutting concerns (auth, logging) | Medium |
| Server Components (RSC) | Architecture | Zero-JS data display, server-side fetching | Medium |
| Error Boundaries | Error Handling | Graceful failure & fallback UI | Low |
| Controlled Components | Forms | Predictable form state management | Low |

## Custom Hooks: The Foundation of Modern React

Custom hooks are the single most important pattern in modern React. They let you extract stateful logic into reusable functions that can be shared across components without changing the component hierarchy. If you learn one pattern from this guide, make it this one.

The key insight: any time you find yourself duplicating `useState` + `useEffect` logic across components, that's a custom hook waiting to be extracted.

```tsx
// useFetch: reusable data fetching with loading & error states
function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    setIsLoading(true);
    fetch(url, { signal: controller.signal })
      .then((res) => res.json())
      .then(setData)
      .catch((err) => {
        if (err.name !== 'AbortError') setError(err);
      })
      .finally(() => setIsLoading(false));

    return () => controller.abort();
  }, [url]);

  return { data, isLoading, error };
}

// Usage: clean, declarative data fetching
function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading } = useFetch<User>(`/api/users/${userId}`);
  if (isLoading) return <Skeleton />;
  return <h1>{user?.name}</h1>;
}
```

```tsx
// useLocalStorage: persist state across sessions
function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}

// Usage
const [theme, setTheme] = useLocalStorage('theme', 'light');
```

**Why custom hooks won.** Custom hooks replaced HOCs and render props for 90% of use cases. They compose naturally (use hooks inside hooks), they don't add wrapper elements to the DOM, and they're trivial to test in isolation. Dan Abramov put it simply: "Hooks aim to solve problems of render props and higher-order components more naturally."

## Compound Components

Compound components let you build flexible UI components that share implicit state between a parent and its children. Think of how `<select>` and `<option>` work together in HTML: the compound component pattern brings that same ergonomic API to your React components.

```tsx
// Compound Tabs component with shared state via Context
const TabsContext = createContext<{
  activeTab: string;
  setActiveTab: (tab: string) => void;
} | null>(null);

function Tabs({ children, defaultTab }: { children: ReactNode; defaultTab: string }) {
  const [activeTab, setActiveTab] = useState(defaultTab);
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

function TabList({ children }: { children: ReactNode }) {
  return <div className="tab-list" role="tablist">{children}</div>;
}

function Tab({ value, children }: { value: string; children: ReactNode }) {
  const ctx = useContext(TabsContext)!;
  return (
    <button
      role="tab"
      aria-selected={ctx.activeTab === value}
      onClick={() => ctx.setActiveTab(value)}
      className={ctx.activeTab === value ? 'active' : ''}
    >
      {children}
    </button>
  );
}

function TabPanel({ value, children }: { value: string; children: ReactNode }) {
  const ctx = useContext(TabsContext)!;
  if (ctx.activeTab !== value) return null;
  return <div role="tabpanel">{children}</div>;
}

// Attach sub-components for clean API
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;

// Usage: intuitive, composable API
<Tabs defaultTab="preview">
  <Tabs.List>
    <Tabs.Tab value="code">Code</Tabs.Tab>
    <Tabs.Tab value="preview">Preview</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="code"><CodeEditor /></Tabs.Panel>
  <Tabs.Panel value="preview"><LivePreview /></Tabs.Panel>
</Tabs>
```

**Where you've seen this.** Radix UI, Headless UI, Reach UI, and most modern component libraries use compound components. It's the pattern behind every accessible accordion, dropdown, and dialog you use. If you're building a [rich document editor](https://www.turbodocx.com/products/turbodocx-writer.md) or design system, this pattern is essential.

## Container/Presentational Pattern

This classic pattern separates **what to show** from **how to get it**. Presentational components are pure functions of their props: they know nothing about APIs, databases, or state management. Container components handle the data fetching and business logic.

In modern React, the "container" is often a custom hook rather than a wrapper component. The separation of concerns remains; only the implementation has evolved.

```tsx
// Presentational: pure UI, zero data knowledge
function UserCard({ name, email, avatar }: UserCardProps) {
  return (
    <div className="card">
      <img src={avatar} alt={name} />
      <h3>{name}</h3>
      <p>{email}</p>
    </div>
  );
}

// Container (classic): wrapper component handles data
function UserCardContainer({ userId }: { userId: string }) {
  const { data: user, isLoading } = useFetch<User>(`/api/users/${userId}`);
  if (isLoading) return <Skeleton />;
  return <UserCard name={user.name} email={user.email} avatar={user.avatar} />;
}

// Container (modern): custom hook IS the container
function useUserData(userId: string) {
  return useFetch<User>(`/api/users/${userId}`);
}

// Usage: hook + presentational component = clean separation
function ProfilePage({ userId }: { userId: string }) {
  const { data: user, isLoading } = useUserData(userId);
  if (isLoading) return <Skeleton />;
  return <UserCard name={user.name} email={user.email} avatar={user.avatar} />;
}
```

**Why this still matters.** Presentational components are trivially testable: pass props, assert output. They're also reusable across different data sources. A `UserCard` that takes props works whether the data comes from REST, GraphQL, or a mock. This is especially valuable when building [API integrations](https://www.turbodocx.com/resources/api-integration-best-practices.md) where data sources may change.

## Render Props & Function as Children

A render prop is a function prop that a component uses to know _what_ to render. Instead of hardcoding the output, the component delegates rendering to its consumer. While custom hooks handle most use cases today, render props remain valuable when you need to share behavior that directly influences the JSX tree.

```tsx
// Render prop: component delegates rendering to consumer
function MouseTracker({ render }: {
  render: (pos: { x: number; y: number }) => ReactNode;
}) {
  const [pos, setPos] = useState({ x: 0, y: 0 });

  return (
    <div onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
      {render(pos)}
    </div>
  );
}

// Usage: consumer controls what gets rendered with mouse data
<MouseTracker
  render={({ x, y }) => (
    <div>
      <Cursor x={x} y={y} />
      <Tooltip position={{ x, y }} text="Follow me!" />
    </div>
  )}
/>
```

**When render props beat hooks.** Use render props when a third-party library needs to inject rendering logic, when the behavior is tightly coupled to JSX structure (animations, transitions), or when you need conditional rendering that depends on the provider's internal state. Libraries like Downshift and React Spring still use render props for good reason.

**How we use this at TurboDocx.** Our [template builder](https://www.turbodocx.com/products/turbodocx-templating.md) uses compound components for the toolbar, variable panel, and preview pane. Each sub-component shares state through Context without prop drilling. Combined with custom hooks for data fetching and state management, this architecture, planned using Claude Code's feature-dev workflow, keeps our components small and composable even as the feature set grows.

## State Reducer Pattern

The state reducer pattern (popularized by Kent C. Dodds) gives consumers full control over how a component's state transitions work. Instead of hardcoding state logic, the component accepts a `stateReducer` prop that can intercept and modify any state change.

```tsx
// useToggle with state reducer: consumer controls transitions
type ToggleState = { on: boolean };
type ToggleAction = { type: 'toggle' } | { type: 'reset' };

function defaultReducer(state: ToggleState, action: ToggleAction): ToggleState {
  switch (action.type) {
    case 'toggle': return { on: !state.on };
    case 'reset': return { on: false };
    default: return state;
  }
}

function useToggle({
  reducer = defaultReducer,
}: { reducer?: typeof defaultReducer } = {}) {
  const [state, dispatch] = useReducer(reducer, { on: false });
  const toggle = () => dispatch({ type: 'toggle' });
  const reset = () => dispatch({ type: 'reset' });
  return { on: state.on, toggle, reset };
}

// Consumer: limit toggles to 4 times max
function App() {
  const [count, setCount] = useState(0);

  const { on, toggle, reset } = useToggle({
    reducer(state, action) {
      if (action.type === 'toggle' && count >= 4) {
        return state; // Block further toggles
      }
      return defaultReducer(state, action);
    },
  });

  return (
    <button onClick={() => { toggle(); setCount((c) => c + 1); }}>
      {on ? 'ON' : 'OFF'} (toggled {count} times)
    </button>
  );
}
```

**When to reach for this pattern.** Use the state reducer pattern when building reusable components or hooks that need to be customizable by consumers. It's the pattern behind Downshift's autocomplete, and it's ideal for design system components where you can't anticipate every use case.

## Provider Pattern & Context Architecture

The Provider pattern uses React Context to pass data through the component tree without manual prop passing at every level. The key to using it well is **splitting contexts by domain**: a monolithic context that holds auth, theme, and notifications causes unnecessary re-renders across your entire app.

```tsx
// Split providers by domain , each context triggers independent re-renders
function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const login = useCallback(async (creds: Credentials) => {
    const user = await authAPI.login(creds);
    setUser(user);
  }, []);
  const logout = useCallback(() => setUser(null), []);

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const toggle = useCallback(() =>
    setTheme((t) => (t === 'light' ? 'dark' : 'light')), [setTheme]
  );

  return (
    <ThemeContext.Provider value={{ theme, toggle }}>
      {children}
    </ThemeContext.Provider>
  );
}

// Compose providers , order matters for dependencies
function AppProviders({ children }: { children: ReactNode }) {
  return (
    <AuthProvider>
      <ThemeProvider>
        <NotificationProvider>
          {children}
        </NotificationProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}
```

```tsx
// Anti-pattern: giant monolithic context
// ✗ Updating notifications re-renders EVERY consumer
const AppContext = createContext({
  user, theme, notifications, settings, permissions
});

// ✓ Split contexts: each domain re-renders independently
const AuthContext = createContext<AuthState>(null);
const ThemeContext = createContext<ThemeState>(null);
const NotificationContext = createContext<NotificationState>(null);
```

For [performance-critical applications](https://www.turbodocx.com/blog/react-performance-optimization.md), consider using Zustand or Jotai with selector-based subscriptions instead of Context for frequently-updating state.

## Higher-Order Components (HOCs)

A higher-order component takes a component as input and returns an enhanced version with additional functionality. While hooks handle most logic-sharing needs, HOCs remain the right choice for **cross-cutting concerns** that wrap an entire component: authentication guards, error boundaries, analytics tracking, and permission checks.

```tsx
// withAuth: redirect unauthenticated users
function withAuth<P extends object>(WrappedComponent: ComponentType<P>) {
  return function AuthenticatedComponent(props: P) {
    const { user, isLoading } = useAuth();

    if (isLoading) return <LoadingSpinner />;
    if (!user) return <Navigate to="/login" />;

    return <WrappedComponent {...props} />;
  };
}

// Usage: any component becomes auth-protected
const ProtectedDashboard = withAuth(Dashboard);
const ProtectedSettings = withAuth(Settings);

// In routes
<Route path="/dashboard" element={<ProtectedDashboard />} />
<Route path="/settings" element={<ProtectedSettings />} />
```

**HOCs aren't dead, they're niche.** Use HOCs for concerns that genuinely wrap an entire component (auth guards, layout wrappers, analytics). For everything else (data fetching, subscriptions, computed values), custom hooks are simpler and don't add wrapper elements to the DOM.

## React Server Components Pattern

React Server Components (RSC) are the biggest architectural shift since hooks. Server Components run entirely on the server, ship **zero JavaScript** to the browser, and can directly access databases and internal APIs. The client receives pure HTML: no hooks, no re-renders, no bundle impact.

```tsx
// Server Component: runs on server, ships zero JS to client
// app/products/page.tsx (no 'use client' directive)
async function ProductList() {
  const products = await db.query('SELECT * FROM products WHERE active = true');

  return (
    <div className="grid grid-cols-3 gap-4">
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
      {/* Client component for interactivity */}
      <AddToCartButton />
    </div>
  );
}

// Client Component: only the interactive parts need JS
// components/AddToCartButton.tsx
'use client';
function AddToCartButton({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);
  return (
    <button onClick={() => { addToCart(productId); setAdded(true); }}>
      {added ? '✓ Added' : 'Add to Cart'}
    </button>
  );
}
```

**Use Server Components for:** Data display, static content, layouts, database queries, markdown rendering, heavy imports (date-fns, syntax highlighters).

**Use Client Components for:** onClick/onChange handlers, useState/useEffect, browser APIs, third-party UI libraries, real-time updates.

The decision framework is simple: start with Server Components by default. Only add `'use client'` when the component needs interactivity, state, effects, or browser-only APIs. Push the client boundary as far down the tree as possible.

## Error Boundary Pattern

A single uncaught error in a component can crash your entire app. Error boundaries catch JavaScript errors anywhere in their child component tree and display a fallback UI instead of a white screen. They're one of the few patterns that still require class components (though libraries like `react-error-boundary` provide functional wrappers).

```tsx
// Error boundary with fallback UI and retry
class ErrorBoundary extends Component<
  { children: ReactNode; fallback?: ReactNode },
  { hasError: boolean; error: Error | null }
> {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    // Log to your error tracking service
    errorReporter.capture(error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="error-fallback">
          <h2>Something went wrong</h2>
          <button onClick={() => this.setState({ hasError: false })}>
            Try again
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

// Usage: wrap sections, not the entire app
<ErrorBoundary fallback={<ChartError />}>
  <RevenueChart />
</ErrorBoundary>
<ErrorBoundary fallback={<TableError />}>
  <TransactionTable />
</ErrorBoundary>
```

**Pro tip: granular boundaries.** Don't wrap your entire app in a single error boundary. Place boundaries around independent features so a crash in the chart widget doesn't take down the navigation. Combine with `Suspense` for unified loading + error states.

## Choosing the Right Pattern

No pattern is universally "best." The right choice depends on your specific problem. Use this decision guide to pick the pattern that fits your situation.

| If you need to... | Use this pattern | Avoid when |
| --- | --- | --- |
| Share stateful logic | Custom Hooks | Logic is purely visual |
| Build flexible UI kits | Compound Components | Component is simple with few variants |
| Separate data from UI | Container/Presentational | Component is already small |
| Dynamic rendering behavior | Render Props | A hook can do the same thing |
| Consumer-controlled state | State Reducer | Simple toggle or boolean state |
| Cross-tree data sharing | Provider Pattern | Data updates frequently (use Zustand) |
| Wrap entire components | HOCs | A hook or Provider works |
| Reduce client JS bundle | Server Components | Component needs interactivity |
| Graceful error handling | Error Boundaries | Errors are in async code (use try/catch) |

**The pragmatic approach.** Start simple. Use custom hooks for logic reuse and container/presentational for structure. Only reach for compound components, state reducers, or HOCs when simpler patterns genuinely don't solve the problem. Set up [automated code review tools](https://www.turbodocx.com/blog/best-claude-code-skills-plugins-mcp-servers.md) to catch pattern misuse before it ships.

## Applying These Patterns to Document-Generation UIs

If you're building a React app that generates documents (contracts, proposals, reports), these patterns become even more valuable. Compound components work beautifully for template editors, the provider pattern simplifies sharing document state across deeply nested form sections, and error boundaries prevent a single malformed template from crashing your entire app.

At TurboDocx, our [API and SDK](https://www.turbodocx.com/products/api-and-sdk.md) are designed so [developers](https://www.turbodocx.com/developers.md) can integrate document generation into well-architected React codebases without sacrificing the clean abstractions these patterns provide.

## Related Resources

- [React Performance Optimization](https://www.turbodocx.com/blog/react-performance-optimization.md): The companion guide to this post, ten techniques to eliminate unnecessary re-renders, shrink your bundle, and ship fast apps.
- [API Integration Best Practices](https://www.turbodocx.com/resources/api-integration-best-practices.md): Production-ready patterns for authentication, error handling, and rate limiting that complement frontend design patterns.
- [TurboDocx for Developers](https://www.turbodocx.com/developers.md): See how developers use our API and SDK to build document automation into their React applications.

## Build Scalable React Apps with TurboDocx

Our API and SDK provide clean, well-structured abstractions for document generation, so you can focus on building great React architecture, not wrestling with file formats.

- [Schedule a Demo](https://www.turbodocx.com/demo)

[Explore the API](https://www.turbodocx.com/products/api-and-sdk.md)
