React & Architecture

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.

Yacine Kahlerras
Yacine KahlerrasSoftware Engineer, Platform & UX at TurboDocx
March 14, 202618 min read

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 or a complex document workflow, these patterns will help you write React code that scales without becoming unmaintainable.

PatternCategoryWhen To UseComplexity
Custom HooksLogic ReuseShared stateful logic across componentsLow
Compound ComponentsComponent APIFlexible, composable UI componentsMedium
Container/PresentationalArchitectureSeparating data from displayLow
Render PropsLogic ReuseDynamic rendering behaviorMedium
State ReducerState MgmtConsumer-controlled state transitionsHigh
Provider PatternData FlowCross-tree data sharingMedium
Higher-Order ComponentsLogic ReuseCross-cutting concerns (auth, logging)Medium
Server Components (RSC)ArchitectureZero-JS data display, server-side fetchingMedium
Error BoundariesError HandlingGraceful failure & fallback UILow
Controlled ComponentsFormsPredictable form state managementLow

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.

// 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>;
}
// 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.

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

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

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

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

// 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>
);
}
// 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, 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.

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

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

// 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 patternAvoid when
Share stateful logicCustom HooksLogic is purely visual
Build flexible UI kitsCompound ComponentsComponent is simple with few variants
Separate data from UIContainer/PresentationalComponent is already small
Dynamic rendering behaviorRender PropsA hook can do the same thing
Consumer-controlled stateState ReducerSimple toggle or boolean state
Cross-tree data sharingProvider PatternData updates frequently (use Zustand)
Wrap entire componentsHOCsA hook or Provider works
Reduce client JS bundleServer ComponentsComponent needs interactivity
Graceful error handlingError BoundariesErrors 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 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 are designed so developers can integrate document generation into well-architected React codebases without sacrificing the clean abstractions these patterns provide.

Related Resources

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.

Yacine Kahlerras
Yacine KahlerrasSoftware Engineer, Platform & UX at TurboDocx