DevToolBoxGRATIS
Blog

React Design Patterns Guide: Compound Components, Custom Hooks, HOC, Render Props & State Machines

20 min readoleh DevToolBox Team
TL;DR: Master 13 essential React design patterns to build scalable, maintainable applications. Use Compound Components for flexible APIs, Custom Hooks for reusable logic, Provider Pattern for dependency injection, and Error Boundaries for resilient UIs. Prefer composition over inheritance, extract stateful logic into hooks, and leverage useReducer for complex state transitions. Choose Controlled components for form validation and Render Props when you need dynamic rendering logic shared across components.
Key Takeaways:
  • Compound Components provide flexible, declarative APIs for component libraries
  • Custom Hooks are the most versatile pattern for logic reuse in modern React
  • Provider Pattern enables clean dependency injection via Context + custom hook
  • useReducer naturally models finite state machines and prevents impossible states
  • Prefer composition over inheritance — use children, render props, and slots
  • Error Boundaries isolate failures and prevent full-app crashes

1. Compound Components

The Compound Components pattern lets a parent component share implicit state with its children. The parent manages internal state while children access it through Context or React.Children. This pattern is common in component libraries for Select/Option, Tabs/Tab, and Accordion components, keeping APIs declarative and flexible.

Rather than passing large configuration objects through props, compound components let consumers declare intent by composing child components. This approach mirrors native HTML patterns like <select> + <option> and lowers the learning curve.

Example: Select and Option

function Select({ children, onChange }) {
  const [value, setValue] = React.useState(null);
  const handleSelect = (val) => {
    setValue(val);
    onChange?.(val);
  };
  return (
    <SelectContext.Provider value={{ value, onSelect: handleSelect }}>
      <div role="listbox">{children}</div>
    </SelectContext.Provider>
  );
}

function Option({ value, children }) {
  const ctx = React.useContext(SelectContext);
  const selected = ctx.value === value;
  return (
    <div role="option" onClick={() => ctx.onSelect(value)}
      style={{ fontWeight: selected ? "bold" : "normal" }}>
      {children}
    </div>
  );
}
Tip: Prefer Context over React.Children.map because Context works reliably with deeply nested and conditionally rendered children.

2. Render Props

Render Props is a technique for sharing rendering logic via a function prop. A component receives a function that returns JSX and passes its internal state to that function. This pattern works well for data fetching, mouse tracking, and intersection observer use cases where logic and rendering need to be decoupled.

While custom hooks have replaced render props in many scenarios, render props remain uniquely valuable when shared logic needs to dynamically determine what gets rendered.

Example: Mouse Position Tracker

function MouseTracker({ render }) {
  const [pos, setPos] = React.useState({ x: 0, y: 0 });
  React.useEffect(() => {
    const handler = (e) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener("mousemove", handler);
    return () => window.removeEventListener("mousemove", handler);
  }, []);
  return render(pos);
}

// Usage:
<MouseTracker
  render={({ x, y }) => (
    <p>Cursor at: {x}, {y}</p>
  )}
/>
Tip: You can use children as a function for a more natural API: <MouseTracker>{(pos) => ...}</MouseTracker>

3. Custom Hooks

Custom Hooks are the most recommended pattern for logic reuse in modern React. By encapsulating stateful logic into functions prefixed with use, you can share it across components without altering the component hierarchy. Common examples include useDebounce, useLocalStorage, and useMediaQuery.

The advantages of custom hooks are: they can call other hooks, return any value, and do not add extra component nesting. Each component using a custom hook gets its own independent copy of the state.

Example: useDebounce and useLocalStorage

function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = React.useState(value);
  React.useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

function useLocalStorage(key, initial) {
  const [val, setVal] = React.useState(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initial;
  });
  React.useEffect(() => {
    localStorage.setItem(key, JSON.stringify(val));
  }, [key, val]);
  return [val, setVal];
}

4. Higher-Order Components (HOC)

A Higher-Order Component is a function that takes a component and returns a new component with added capabilities. Common HOCs include withAuth (authentication guard), withLoading (loading state), and withTheme (theme injection). Multiple HOCs can be composed together using a compose utility.

Downsides of HOCs include potential prop name collisions, deeper component trees that complicate debugging, and static methods not being forwarded automatically. In modern React, prefer custom hooks and only use HOCs when you need to wrap rendering logic.

Example: withAuth HOC

function withAuth(WrappedComponent) {
  return function AuthenticatedComponent(props) {
    const { user, loading } = useAuth();
    if (loading) return <Spinner />;
    if (!user) return <Redirect to="/login" />;
    return <WrappedComponent {...props} user={user} />;
  };
}

// Compose multiple HOCs:
const compose = (...fns) =>
  fns.reduce((f, g) => (...args) => f(g(...args)));

const EnhancedPage = compose(
  withAuth,
  withTheme,
  withLoading
)(DashboardPage);

5. Container / Presentational

The Container/Presentational pattern separates data logic from UI rendering. Container components handle data fetching, state management, and side effects, while presentational components receive props and render UI. Although hooks have reduced the need for this pattern, it still helps maintain clear separation of concerns in large codebases.

Presentational components are usually pure functional components that are easy to test and reuse. Containers can be replaced with custom hooks, but the separation principle remains valuable — keeping "what to do" separate from "how to display it".

Example: User List Separation

// Container: handles data and state
function UserListContainer() {
  const [users, setUsers] = React.useState([]);
  React.useEffect(() => {
    fetch("/api/users").then(r => r.json()).then(setUsers);
  }, []);
  return <UserListView users={users} />;
}

// Presentational: pure UI rendering
function UserListView({ users }) {
  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>{u.name} — {u.email}</li>
      ))}
    </ul>
  );
}

6. Provider Pattern

The Provider Pattern combines React Context with a custom hook for clean dependency injection. A Provider component wraps part of the tree, making values available to all descendants. A custom hook like useTheme consumes the context, eliminating prop drilling entirely.

This is the foundational architecture behind state management libraries like Redux, Zustand, and React Query. Adding an error check in the custom hook to ensure it is used within the Provider is a best practice that provides clear error messages during development.

Example: Theme Provider

const ThemeContext = React.createContext(null);

function ThemeProvider({ children }) {
  const [theme, setTheme] = React.useState("light");
  const toggle = () => setTheme(t => t === "light" ? "dark" : "light");
  return (
    <ThemeContext.Provider value={{ theme, toggle }}>
      {children}
    </ThemeContext.Provider>
  );
}

function useTheme() {
  const ctx = React.useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be inside ThemeProvider");
  return ctx;
}
Tip: Wrap the Context value with useMemo to prevent unnecessary re-renders of all consumers when the Provider parent re-renders.

7. State Machines

The State Machine pattern uses finite states and explicit transitions to manage complex UI logic. useReducer is a natural fit because each action represents a valid state transition. This pattern prevents impossible states and makes multi-step forms, async workflows, and complex interactions easier to reason about and test.

For more complex state machine requirements, consider using the XState library which provides visual debugging tools and stricter type safety. For simpler scenarios, useReducer with a switch statement is sufficient.

Example: Async Request State Machine

function fetchReducer(state, action) {
  switch (state.status) {
    case "idle":
      if (action.type === "FETCH") return { status: "loading" };
      break;
    case "loading":
      if (action.type === "SUCCESS")
        return { status: "success", data: action.data };
      if (action.type === "ERROR")
        return { status: "error", error: action.error };
      break;
    case "error":
      if (action.type === "RETRY") return { status: "loading" };
      break;
  }
  return state;
}

const [state, dispatch] = React.useReducer(
  fetchReducer, { status: "idle" }
);

8. Controlled vs Uncontrolled

Controlled components store form values in React state and update via onChange handlers. Uncontrolled components let the DOM manage values and read them via refs. Use controlled components when you need validation, conditional rendering, or derived state. Use uncontrolled for simple forms, file inputs, or integrating with non-React libraries.

React.forwardRef and useImperativeHandle allow parent components to access internal methods of uncontrolled components via refs. This is useful for imperative operations like focusing inputs or resetting forms.

Example: Controlled vs Uncontrolled

// Controlled: React owns the value
function ControlledInput() {
  const [value, setValue] = React.useState("");
  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

// Uncontrolled: DOM owns the value
const UncontrolledInput = React.forwardRef((props, ref) => {
  const inputRef = React.useRef(null);
  React.useImperativeHandle(ref, () => ({
    getValue: () => inputRef.current.value,
  }));
  return <input ref={inputRef} defaultValue={props.initial} />;
});

9. Composition vs Inheritance

React officially recommends composition over inheritance. Through the children prop, render props, and slots pattern, you can customize component behavior without creating tight coupling. Across thousands of React components at Facebook, no use case was found where inheritance was superior to composition.

The Slots pattern passes JSX fragments through named props, providing capabilities similar to Vue slots. This is more flexible than inheritance because each slot can be arbitrary JSX, while inheritance can only extend along the class hierarchy.

Example: Slots Pattern

// Slots pattern: named regions via props
function Card({ header, body, footer }) {
  return (
    <div style={{ border: "1px solid #ccc", borderRadius: 8 }}>
      <div style={{ padding: 16, borderBottom: "1px solid #eee" }}>
        {header}
      </div>
      <div style={{ padding: 16 }}>{body}</div>
      {footer && (
        <div style={{ padding: 16, borderTop: "1px solid #eee" }}>
          {footer}
        </div>
      )}
    </div>
  );
}

// Usage: fully customizable without inheritance
<Card
  header={<h3>Title</h3>}
  body={<p>Content here</p>}
  footer={<button>Save</button>}
/>

10. Observer Pattern

The Observer Pattern (pub/sub) decouples component communication in React. Through an event emitter, components can publish events without knowing who listens, and subscribers do not need to know where events originate. This is useful for cross-tree communication and integration with external systems.

When using the Observer Pattern in React, always unsubscribe in the useEffect cleanup function to prevent memory leaks. For simple cross-component communication, Context or state management libraries are usually a better choice.

Example: Event Emitter

function createEventBus() {
  const listeners = new Map();
  return {
    on(event, cb) {
      if (!listeners.has(event)) listeners.set(event, new Set());
      listeners.get(event).add(cb);
      return () => listeners.get(event).delete(cb);
    },
    emit(event, data) {
      listeners.get(event)?.forEach(cb => cb(data));
    },
  };
}

// Usage in a hook:
function useEventBus(bus, event, handler) {
  React.useEffect(() => bus.on(event, handler),
    [bus, event, handler]);
}

11. Proxy Pattern

The Proxy Pattern in React enables lazy loading and virtualization. A proxy component acts as a stand-in for the real component, loading the actual content only when conditions are met (e.g., entering the viewport). React.lazy and Intersection Observer are common tools for implementing this pattern.

Virtual scrolling is another application of the Proxy Pattern: only list items within the viewport are rendered, while invisible portions are replaced with placeholder elements. This is essential for long lists with thousands of entries.

Example: Viewport Lazy Loading

function LazyVisible({ children, fallback = null }) {
  const ref = React.useRef(null);
  const [visible, setVisible] = React.useState(false);
  React.useEffect(() => {
    const obs = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setVisible(true);
        obs.disconnect();
      }
    });
    if (ref.current) obs.observe(ref.current);
    return () => obs.disconnect();
  }, []);
  return (
    <div ref={ref}>
      {visible ? children : fallback}
    </div>
  );
}

12. Module Pattern

The Module Pattern in React manifests as barrel exports, feature modules, and code splitting. By organizing related components, hooks, and utilities in a single directory with index.ts as the public API, you keep code clean and maintainable. React.lazy and dynamic imports enable on-demand loading.

The benefit of feature modules is that team members can independently develop their assigned modules. Modules interact through well-defined public APIs while internal implementation details are encapsulated. This also makes code splitting boundaries much clearer.

Example: Feature Module Structure

// features/auth/index.ts — barrel export
export { AuthProvider } from "./AuthProvider";
export { useAuth } from "./useAuth";
export { LoginForm } from "./LoginForm";
export { SignupForm } from "./SignupForm";

// Lazy loading a feature module:
const AuthModule = React.lazy(() => import("./features/auth"));

function App() {
  return (
    <React.Suspense fallback={<Spinner />}>
      <AuthModule />
    </React.Suspense>
  );
}
Tip: Be aware that barrel exports can impact tree-shaking. For large libraries, consider using direct import paths instead of barrel exports.

13. Error Boundary Pattern

Error Boundaries are class components that catch JavaScript errors in their child component tree and display a fallback UI instead of crashing the entire app. They use componentDidCatch and getDerivedStateFromError lifecycle methods. Using multiple Error Boundaries across different UI sections isolates failures.

Error Boundaries only catch errors during rendering, lifecycle methods, and constructors. They cannot catch errors in event handlers, async code, or server-side rendering. For those scenarios, use try/catch or global error handlers.

Example: Error Boundary with Retry

class ErrorBoundary extends React.Component {
  state = { hasError: false, error: null };

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

  componentDidCatch(error, info) {
    console.error("Caught:", error, info.componentStack);
  }

  handleRetry = () => this.setState({ hasError: false, error: null });

  render() {
    if (this.state.hasError) {
      return (
        <div style={{ padding: 20, textAlign: "center" }}>
          <h3>Something went wrong</h3>
          <button onClick={this.handleRetry}>Try Again</button>
        </div>
      );
    }
    return this.props.children;
  }
}
Tip: In production, send errors from componentDidCatch to an error monitoring service like Sentry for timely detection and resolution.

Pattern Evolution: From Mixins to Hooks

React design patterns have evolved alongside the framework itself. Understanding this evolution helps explain why certain patterns are recommended and when older patterns still make sense.

  • Mixins (2013-2015): The earliest logic reuse mechanism in React, only supported with createClass. Deprecated due to naming conflicts and implicit dependencies.
  • HOC (2015-2018): Wrapping components in functions to inject behavior. Solved Mixin issues but introduced wrapper hell and prop collisions.
  • Render Props (2017-2019): Sharing logic via function props. More explicit than HOCs but still had component nesting issues.
  • Hooks (2019-present): Revolutionary logic reuse solution. No nesting, no naming conflicts, highly composable. The currently recommended approach.

While hooks are the preferred approach, HOCs remain useful in certain scenarios like route guards and permission control, Render Props retain value when dynamic render decisions are needed, and Error Boundaries still require class components.

Real-World Pattern Combinations

In real projects, design patterns are rarely used in isolation. Here are common pattern combinations:

Provider + Custom Hook + Error Boundary

This is the most common combination. The Provider supplies the data source, a custom hook encapsulates access logic, and an Error Boundary handles load failures. For example, an auth layer: AuthProvider supplies user info, useAuth hook lets components access auth state, and an Error Boundary shows fallback UI on API failures.

Compound Components + Provider + State Machine

When building a multi-step form wizard, the outer layer uses compound components to define steps (Wizard + Step), the Provider shares wizard state internally, and a useReducer state machine manages step transitions and validation logic.

Module Pattern + Proxy + Code Splitting

Large applications use the Module Pattern to organize feature boundaries, the Proxy Pattern (React.lazy + Suspense) to load modules on demand, and route-level code splitting to achieve optimal initial load performance.

Testing Design Patterns

Good design patterns should make testing easier, not harder. Here are testing tips for each pattern:

  • Custom Hooks: Use renderHook from @testing-library/react to test hook logic independently.
  • Compound Components: Render parent and children together, testing interaction behavior rather than implementation details.
  • Provider Pattern: Create test Provider wrappers, inject mock data, and test consumer component behavior.
  • State Machines: Reducers are pure functions that can be unit tested for each state transition without React.
  • Error Boundaries: Intentionally render children that throw errors and verify the fallback UI renders correctly.
  • Presentational: Use snapshot or visual regression tests since presentational components are pure functions.

Pattern Selection Guide

Choosing the right design pattern depends on your specific needs. Here is a quick reference organized by use case:

  • Logic Reuse → Custom Hooks (preferred) or Render Props
  • Component Library APIs → Compound Components
  • Global State / DI → Provider Pattern
  • Complex State Logic → State Machines (useReducer / XState)
  • Cross-Cutting Concerns → HOCs (use sparingly, prefer hooks)
  • Error Recovery → Error Boundaries
  • Performance → Proxy Pattern (lazy load / virtualization)
  • Code Organization → Module Pattern (barrel exports + code splitting)
  • Form Handling → Controlled (validation) / Uncontrolled (simple forms)
  • Loose Coupling → Observer Pattern (event bus)

Common Anti-Patterns to Avoid

Understanding anti-patterns is just as important as understanding design patterns. Avoiding these common pitfalls significantly improves code quality:

  • Prop Drilling: When passing props more than 3 levels deep, consider Context or a state management library. Deep prop passing creates coupling and makes refactoring difficult.
  • Premature Abstraction: Do not create abstractions with only one use case. Follow the Rule of Three — only extract when a pattern repeats three times.
  • Giant Components: Components exceeding 200 lines should be split into smaller subcomponents or custom hooks. Large components are hard to test and maintain.
  • Components in Render: Defining components inside render creates new instances each render, breaking reconciliation and state persistence.
  • useEffect Abuse: Do not use useEffect to synchronize derived state. If a value can be computed from props or state, compute it directly during rendering.

Performance Considerations

Design patterns affect not only code maintainability but also runtime performance. Here are performance considerations for each pattern:

  • Context: Every consumer re-renders when the Context value changes. Optimize by splitting Context (separate read and write) or wrapping the value with useMemo.
  • Compound Components: Wrap child components with React.memo to prevent unnecessary re-renders when parent state changes.
  • HOC: Ensure the component returned by the HOC is properly memoized. Avoid creating new HOC instances on each render.
  • Event Bus: Frequently triggered events like mouse movements should use throttle or debounce to limit callback execution frequency.
  • Code Splitting: Network requests from React.lazy affect perceived performance. Use preloading strategies like loading on hover to reduce user wait times.
Tip: Use the React DevTools Profiler to identify unnecessary re-renders. Ensure correctness first, then optimize — do not optimize prematurely.

When Not to Use Patterns

Design patterns are tools for solving specific problems, not tools for showing off. In the following scenarios, simple straightforward code is better than elaborate patterns:

  • Prototype or MVP phase — validating ideas quickly is more important than code architecture
  • Logic used by only one component — no need to extract a custom hook
  • Props passed only two levels deep — no need to introduce Context
  • State with only two branches — no need for a state machine, a simple useState + if/else is enough
  • The team is unfamiliar with a pattern — readability and team velocity matter more than "best practices"

Conclusion

React design patterns are not mutually exclusive — they are often combined. A typical React application might use the Provider Pattern for theming and auth, Custom Hooks for shared business logic, Compound Components for reusable UI libraries, and Error Boundaries for handling exceptions.

The key is understanding what problem each pattern solves, choosing the best fit for your scenario, and avoiding over-engineering. Start simple and gradually introduce more advanced patterns as requirements grow. Remember: the best pattern is one that your team can understand and maintain.

With the introduction of React Server Components and new compiler optimizations (React Compiler), some patterns may continue to evolve. But the core principles remain unchanged: separation of concerns, single responsibility, composition over inheritance, and keeping APIs simple. Master the underlying ideas behind these patterns, and you will adapt quickly to any new framework or paradigm.

Recommended Resources

  • React Official Docs: The "Thinking in React" and "Reusing Logic with Custom Hooks" sections on react.dev are the best starting point for understanding pattern philosophy.
  • Patterns.dev: A free resource maintained by Lydia Hallie and Addy Osmani with interactive explanations of design and rendering patterns.
  • XState: If state machines interest you, XState provides a visual editor and complete finite state machine implementation.
  • Testing Library: The @testing-library/react docs demonstrate how to test various patterns in a user-centric way.

Design patterns are distilled experience. Reading open source code from libraries like Radix UI, Headless UI, and React Query to observe how these mature libraries apply these patterns is one of the most effective ways to improve your architecture skills.

𝕏 Twitterin LinkedIn
Apakah ini membantu?

Tetap Update

Dapatkan tips dev mingguan dan tool baru.

Tanpa spam. Berhenti kapan saja.

Coba Alat Terkait

{ }JSON FormatterJSTypeScript to JavaScript.*Regex Tester

Artikel Terkait

Advanced TypeScript Guide: Generics, Conditional Types, Mapped Types, Decorators, and Type Narrowing

Master advanced TypeScript patterns. Covers generic constraints, conditional types with infer, mapped types (Partial/Pick/Omit), template literal types, discriminated unions, utility types deep dive, decorators, module augmentation, type narrowing, covariance/contravariance, and satisfies operator.

Clean Code Guide: Naming Conventions, SOLID Principles, Code Smells, Refactoring & Best Practices

Comprehensive clean code guide covering naming conventions, function design, SOLID principles, DRY/KISS/YAGNI, code smells and refactoring, error handling patterns, testing, code review, design by contract, and clean architecture.

Functional Programming Guide: Pure Functions, Immutability, Monads, Composition & FP in JavaScript/TypeScript

Complete functional programming guide covering pure functions, immutability, higher-order functions, monads, functors, composition, pattern matching, and practical FP in JavaScript and TypeScript.