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.
| 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.
// useFetch: reusable data fetching with loading & error statesfunction 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 fetchingfunction 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 sessionsfunction 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;}// Usageconst [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 Contextconst 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 (<buttonrole="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 APITabs.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 knowledgefunction 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 datafunction 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 containerfunction useUserData(userId: string) {return useFetch<User>(`/api/users/${userId}`);}// Usage: hook + presentational component = clean separationfunction 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 consumerfunction 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<MouseTrackerrender={({ 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 transitionstype 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 maxfunction 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-rendersfunction 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 dependenciesfunction AppProviders({ children }: { children: ReactNode }) {return (<AuthProvider><ThemeProvider><NotificationProvider>{children}</NotificationProvider></ThemeProvider></AuthProvider>);}
// Anti-pattern: giant monolithic context// ✗ Updating notifications re-renders EVERY consumerconst AppContext = createContext({user, theme, notifications, settings, permissions});// ✓ Split contexts: each domain re-renders independentlyconst 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 usersfunction 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-protectedconst 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 retryclass 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 serviceerrorReporter.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 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
React Performance Optimization
The companion guide to this post — ten techniques to eliminate unnecessary re-renders, shrink your bundle, and ship fast apps.
API Integration Best Practices
Production-ready patterns for authentication, error handling, and rate limiting that complement frontend design patterns.
TurboDocx for Developers
See how developers use our API and SDK to build document automation into their React applications.
Ship Features in One Session with Claude Code
The workflow methodology for shipping merge-ready features in 45–90 minutes, including architectural planning.
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.
