Skip to content

React Design Patterns — Compound Components, Render Props, HOCs

DodaTech 9 min read

In this tutorial, you'll learn about React Design Patterns. We cover key concepts, practical examples, and best practices.

What You'll Learn

Master four essential React design patterns — compound components, render props, higher-order components (HOCs), and custom hooks — so you can build reusable, composable, and maintainable user interfaces.

Why It Matters

React gives you the building blocks. Design patterns give you the blueprints. Without patterns, your code becomes tangled, components grow unmanageable, and reusability plummets. Teams that adopt patterns ship faster and debug less.

Real-World Use

A UI library like Chakra UI or Radix uses compound components for its Tabs and Accordion. React Router uses render props for route matching. Authentication wrappers across dashboards rely on HOCs to gate access.

What Are React Design Patterns?

React design patterns are proven, repeatable solutions to common UI architecture problems. They are not libraries. They are approaches — ways of structuring components so they stay flexible, testable, and composable as your app grows.

Think of them like design patterns in software engineering: the Singleton or Observer patterns you might know from JavaScript. React patterns solve frontend-specific problems like sharing state between components, reusing component logic, and keeping the API surface clean for consumers of your component library.

We will cover four major patterns:

Pattern Core Idea Best For
Compound Components Multiple components that share implicit state Tabs, Accordions, Select menus
Render Props A prop that is a function returning JSX Cross-cutting concerns, flexibility
Higher-Order Components A function that enhances a component Auth guards, logging, data injection
Custom Hooks A function that encapsulates stateful logic Data fetching, form handling, browser APIs

Compound Components

Compound components work like HTML's <select> and <option> — parent and children share implicit state through context, not props.

Tabs Example

import { createContext, useContext, useState } from "react";

const TabsContext = createContext();

function Tabs({ children, defaultTab }) {
  const [activeTab, setActiveTab] = useState(defaultTab);

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      {children}
    </TabsContext.Provider>
  );
}

function Tab({ label, children }) {
  const { activeTab, setActiveTab } = useContext(TabsContext);

  return (
    <button
      onClick={() => setActiveTab(label)}
      style={{
        padding: "8px 16px",
        fontWeight: activeTab === label ? "bold" : "normal",
        borderBottom: activeTab === label ? "2px solid #007bff" : "2px solid transparent",
        background: "none",
        cursor: "pointer",
      }}
    >
      {children}
    </button>
  );
}

function TabPanel({ label, children }) {
  const { activeTab } = useContext(TabsContext);
  if (activeTab !== label) return null;
  return <div style={{ padding: "16px 0" }}>{children}</div>;
}

// Usage
function App() {
  return (
    <Tabs defaultTab="profile">
      <Tab label="profile">Profile</Tab>
      <Tab label="settings">Settings</Tab>
      <TabPanel label="profile">Your profile content here.</TabPanel>
      <TabPanel label="settings">App settings here.</TabPanel>
    </Tabs>
  );
}

Expected output: Three clickable tabs. Only the active tab's panel shows. Clicking a different tab swaps the visible content and highlights the selected tab label.

Why This Works

The user of Tabs never sees setActiveTab or TabsContext. They just declare what they want — tabs and panels — and the wiring happens automatically. This is the hallmark of compound components: simple API, complex behavior.

Accordion Example

const AccordionContext = createContext();

function Accordion({ children }) {
  const [openIndex, setOpenIndex] = useState(null);

  return (
    <AccordionContext.Provider value={{ openIndex, setOpenIndex }}>
      <div style={{ border: "1px solid #ddd", borderRadius: "4px" }}>
        {children}
      </div>
    </AccordionContext.Provider>
  );
}

function AccordionItem({ index, title, children }) {
  const { openIndex, setOpenIndex } = useContext(AccordionContext);
  const isOpen = openIndex === index;

  return (
    <div style={{ borderBottom: "1px solid #eee" }}>
      <button
        onClick={() => setOpenIndex(isOpen ? null : index)}
        style={{
          width: "100%",
          padding: "12px 16px",
          textAlign: "left",
          background: isOpen ? "#f5f5f5" : "#fff",
          border: "none",
          cursor: "pointer",
          fontWeight: isOpen ? "bold" : "normal",
        }}
      >
        {title}
      </button>
      {isOpen && (
        <div style={{ padding: "12px 16px" }}>{children}</div>
      )}
    </div>
  );
}

// Usage
function FaqSection() {
  return (
    <Accordion>
      <AccordionItem index={0} title="What is React?">
        React is a JavaScript library for building user interfaces.
      </AccordionItem>
      <AccordionItem index={1} title="What are hooks?">
        Hooks are functions that let you use state and lifecycle in function components.
      </AccordionItem>
    </Accordion>
  );
}

Expected output: A bordered accordion with two items. Clicking an item expands its content and collapses the other. Clicking the open item collapses it.

Render Props Pattern

A render prop is a prop that receives a function which returns JSX. The parent component calls that function and passes data into it, letting the child decide how to render.

function MouseTracker({ render }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  function handleMouseMove(event) {
    setPosition({ x: event.clientX, y: event.clientY });
  }

  return (
    <div
      onMouseMove={handleMouseMove}
      style={{ height: "200px", border: "1px solid #ccc", padding: "16px" }}
    >
      {render(position)}
    </div>
  );
}

// Usage
function App() {
  return (
    <MouseTracker
      render={({ x, y }) => (
        <p>
          Mouse position: {x}, {y}
        </p>
      )}
    />
  );
}

Expected output: A bordered box. As you move the mouse inside it, the coordinates update in real time.

When Render Props Shine

Render props give the consumer full control over rendering. The MouseTracker does not care what you display — it only provides the mouse position. This makes it extremely reusable across projects.

Higher-Order Components (HOCs)

A higher-order component is a function that takes a component and returns an enhanced component.

function withAuth(Component) {
  function AuthenticatedComponent(props) {
    const isLoggedIn = !!localStorage.getItem("token");

    if (!isLoggedIn) {
      return (
        <div style={{ textAlign: "center", padding: "40px" }}>
          <h2>Access Denied</h2>
          <p>Please log in to view this page.</p>
        </div>
      );
    }

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

  return AuthenticatedComponent;
}

// Usage
function Dashboard() {
  return <h1>Welcome to your dashboard</h1>;
}

const ProtectedDashboard = withAuth(Dashboard);

Expected output: If no token is in localStorage, the user sees an "Access Denied" message. Otherwise, they see the Dashboard.

HOCs for Data Injection

function withUser(Component) {
  function UserWrapper(props) {
    const [user, setUser] = useState(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
      fetch("/api/user")
        .then(res => res.json())
        .then(data => {
          setUser(data);
          setLoading(false);
        });
    }, []);

    return <Component {...props} user={user} loading={loading} />;
  }

  return UserWrapper;
}

const ProfilePageWithUser = withUser(ProfilePage);

Note: HOCs can compose: withAuth(withUser(ProfilePage)). This chaining is powerful but can lead to "wrapper hell" if overused.

Custom Hooks vs HOCs

TypeScript and modern React prefer custom hooks over HOCs for most use cases.

// HOC approach
function withWindowWidth(Component) {
  return function Enhanced(props) {
    const [width, setWidth] = useState(window.innerWidth);
    useEffect(() => {
      const handler = () => setWidth(window.innerWidth);
      window.addEventListener("resize", handler);
      return () => window.removeEventListener("resize", handler);
    }, []);
    return <Component {...props} windowWidth={width} />;
  };
}

// Custom hook approach — cleaner
function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);
  useEffect(() => {
    const handler = () => setWidth(window.innerWidth);
    window.addEventListener("resize", handler);
    return () => window.removeEventListener("resize", handler);
  }, []);
  return width;
}

// Usage comparison
function MyComponent({ windowWidth }) {
  return <p>Width: {windowWidth}</p>;
}
const MyComponentWithWidth = withWindowWidth(MyComponent);

function MyComponent() {
  const width = useWindowWidth();
  return <p>Width: {width}</p>;
}
Aspect HOC Custom Hook
Naming collision risk Yes (prop names) No
Wrapper DOM nodes Yes No
Debugging Wrapper hell Flat tree
Composition Chaining Composition by calling
TypeScript inference Tricky Natural

When to Use Each Pattern

Compound Components → Shared implicit state (Tabs, Accordion, Select)
Render Props        → Consumer controls rendering (MouseTracker, DataProvider)
HOCs                → Legacy codebases, class components, cross-cutting concerns
Custom Hooks        → Everything else — data fetching, forms, browser APIs
flowchart TD
    A[Need to share state between components?] -->|Yes| B[Compound Components]
    A -->|No| C[Need to inject behavior into components?]
    C -->|Yes| D[Is the consumer a class component?]
    D -->|Yes| E[HOC]
    D -->|No| F[Custom Hook]
    C -->|No| G[Need flexible rendering control?]
    G -->|Yes| H[Render Props]
    G -->|No| I[Use basic props]

Learning Path

flowchart LR
    A[React Basics] --> B[Hooks Guide]
    B --> C[Context API]
    C --> D[Custom Hooks]
    D --> E[Design Patterns]
    E --> F[Error Boundaries]
    F --> G[API Integration]
    G --> H[Final Project]
    E -->|You are here| E

Common Errors

1. Mutating Context Directly in Compound Components

Always use state setters inside the provider. Mutating the context value object directly does not trigger re-renders.

2. Forgetting to Return from a Render Prop

If your render function does not return JSX, you get undefined rendered. Always include a return.

3. HOC Naming Collisions

An HOC that injects a user prop will silently overwrite a user prop passed from the parent. Use naming conventions or namespaced props.

4. Wrapper Hell

Composing five HOCs creates five nested wrappers in the React DevTools. Prefer custom hooks or reduce the number of HOCs.

5. Calling Hooks Inside HOCs

Hooks must be called inside a component, not inside the HOC factory function. Always put useState/useEffect inside the inner component.

6. Over-Engineering

Not every reusable piece of logic needs a pattern. If a simple prop works, use a simple prop. Patterns add abstraction cost.

7. Breaking the Rules of Hooks in Custom Hooks

Custom hooks must follow the same rules: only call hooks at the top level, never inside conditions or loops.

Practice Questions

What is the main benefit of compound components?

Compound components share implicit state through React Context without exposing internal state management to the consumer. The user just declares the structure and the parent handles coordination.

How do render props differ from HOCs?

Render props use a function prop to let the consumer control rendering, while HOCs wrap the entire component and inject props. Render props offer more flexibility but can lead to deeply nested JSX.

Why do custom hooks often replace HOCs in modern React?

Custom hooks do not create wrapper components, avoid prop naming collisions, and integrate naturally with function components. They also compose by simple function calls rather than nested wrappers.

When should I still use an HOC instead of a hook?

Use HOCs when working with class components (which cannot use hooks), when you need to wrap a third-party component that you cannot modify, or when the enhancement must be applied at the component definition level.

Can I combine multiple patterns in one component?

Yes. A component library might use compound components for its API surface and custom hooks internally for logic. Patterns are tools, not religions

Challenge

Build a compound <Stepper> component with <Step> and <StepPanel> children. Include next, back, and goTo(index) navigation. The consumer should be able to write:

<Stepper>
  <Step label="Personal Info" />
  <Step label="Payment" />
  <Step label="Confirmation" />
  <StepPanel>Personal details form</StepPanel>
  <StepPanel>Payment form</StepPanel>
  <StepPanel>Review and submit</StepPanel>
</Stepper>

Real-World Task

Refactor a component in your current project that passes five unrelated props through three levels of nesting. Choose the appropriate pattern (compound, render prop, or custom hook) to eliminate the prop drilling. Measure the reduction in lines of code and props passed.


This tutorial is built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — tools that rely on well-architected component patterns for their desktop UI.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro