Advanced30 min read

Advanced Patterns

Master advanced React component patterns including compound components, render props, higher-order components, and the provider pattern to build flexible, reusable UI architectures.

Why Patterns Matter

As your React applications grow, you start noticing recurring problems: How do you share logic between components? How do you build flexible components that work in many contexts? How do you keep components loosely coupled?

Design patterns are proven solutions to these recurring problems. In React, several patterns have emerged over the years, each addressing a specific challenge:

PatternBest For
Compound ComponentsComponents that work together as a group (tabs, accordions, selects)
Render PropsSharing stateful logic with full control over rendered output
Higher-Order ComponentsWrapping components to inject additional behavior
Custom HooksSharing stateful logic in the simplest way (the modern approach)
Provider PatternMaking data available to a deep component tree without prop drilling

No single pattern is "the best" — each solves a different problem. Understanding all of them helps you pick the right tool for each situation and understand existing codebases that use older patterns.

We will explore each one with practical examples.

Compound Components Pattern

The compound components pattern lets you create a set of components that work together to form a complete UI element, while giving the consumer full control over the structure and order.

Think of the native HTML <select> and <option> tags:

html
<select>
  <option value="a">Option A</option>
  <option value="b">Option B</option>
</select>

The <select> manages the state (which option is selected), and each <option> reads from that shared state. They only make sense together — that is a compound component.

Here is how to build a compound Tabs component in React:

jsx
const TabsContext = React.createContext();

function Tabs({ children, defaultIndex = 0 }) {
  const [activeIndex, setActiveIndex] = React.useState(defaultIndex);

  return (
    <TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
      <div>{children}</div>
    </TabsContext.Provider>
  );
}

function TabList({ children }) {
  return <div role="tablist">{children}</div>;
}

function Tab({ index, children }) {
  const { activeIndex, setActiveIndex } = React.useContext(TabsContext);
  const isActive = index === activeIndex;

  return (
    <button
      role="tab"
      className={isActive ? 'active' : ''}
      onClick={() => setActiveIndex(index)}
    >
      {children}
    </button>
  );
}

function TabPanel({ index, children }) {
  const { activeIndex } = React.useContext(TabsContext);
  if (index !== activeIndex) return null;
  return <div role="tabpanel">{children}</div>;
}

Usage:

jsx
<Tabs>
  <TabList>
    <Tab index={0}>Profile</Tab>
    <Tab index={1}>Settings</Tab>
  </TabList>
  <TabPanel index={0}><p>Profile content</p></TabPanel>
  <TabPanel index={1}><p>Settings content</p></TabPanel>
</Tabs>

The parent Tabs manages state via context, and children consume it. The consumer controls the layout and ordering while the compound component manages the behavior.

Render Props and Higher-Order Components

Render Props is a pattern where a component receives a function as a prop (or as children) and calls it with some data:

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

  React.useEffect(() => {
    const handleMove = (e) => setPosition({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', handleMove);
    return () => window.removeEventListener('mousemove', handleMove);
  }, []);

  return render(position);
}

// Usage
<MouseTracker render={({ x, y }) => (
  <p>Mouse is at ({x}, {y})</p>}
/>

The MouseTracker manages the logic (tracking mouse position) and lets the consumer decide how to render it. You can also use children as a function:

jsx
<MouseTracker>
  {({ x, y }) => <p>Mouse is at ({x}, {y})</p>}
</MouseTracker>

Higher-Order Components (HOC) are functions that take a component and return a new component with additional behavior:

jsx
function withAuth(WrappedComponent) {
  return function AuthenticatedComponent(props) {
    const user = useAuth();
    if (!user) return <p>Please log in</p>;
    return <WrappedComponent {...props} user={user} />;
  };
}

const ProtectedDashboard = withAuth(Dashboard);

HOCs were very popular before hooks arrived. They follow a naming convention: withSomething. Common examples include withRouter from React Router (v5) and connect from Redux.

When to use which:

  • Render props give the consumer maximum flexibility over the rendered output.
  • HOCs are good for cross-cutting concerns (auth checks, logging) but can lead to "wrapper hell" — many nested HOCs become hard to debug.
  • Custom hooks have largely replaced both patterns for sharing stateful logic. Prefer hooks when possible.

The Provider Pattern and Custom Hooks

The provider pattern uses React Context to make data available to an entire subtree without passing props through every level:

jsx
const ThemeContext = React.createContext('light');

function ThemeProvider({ children }) {
  const [theme, setTheme] = React.useState('light');

  const toggle = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };

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

// Custom hook for consuming the context
function useTheme() {
  const context = React.useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

This is the recommended modern pattern for most state sharing needs. The provider encapsulates the state logic, and the custom hook provides a clean API for consumers:

jsx
function Header() {
  const { theme, toggle } = useTheme();
  return (
    <header style={{ background: theme === 'dark' ? '#333' : '#fff' }}>
      <button onClick={toggle}>Toggle theme</button>
    </header>
  );
}

function App() {
  return (
    <ThemeProvider>
      <Header />
      <Main />
    </ThemeProvider>
  );
}

Custom hooks are the most modern and recommended way to share logic. They are just functions that use React hooks:

jsx
function useWindowSize() {
  const [size, setSize] = React.useState({
    width: window.innerWidth,
    height: window.innerHeight
  });

  React.useEffect(() => {
    const handleResize = () => {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    };
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

Custom hooks replaced render props and HOCs as the primary pattern for code reuse because they are simpler, composable, and produce no extra components in the tree.

Controlled vs Uncontrolled Pattern

The controlled vs uncontrolled pattern determines who owns the state — the component itself or its parent.

Uncontrolled component — manages its own state:

jsx
function Toggle() {
  const [isOn, setIsOn] = React.useState(false);
  return (
    <button onClick={() => setIsOn(!isOn)}>
      {isOn ? 'ON' : 'OFF'}
    </button>
  );
}

// Usage — you have no control over the state
<Toggle />

Controlled component — state is managed by the parent:

jsx
function Toggle({ isOn, onToggle }) {
  return (
    <button onClick={onToggle}>
      {isOn ? 'ON' : 'OFF'}
    </button>
  );
}

// Usage — parent controls the state
const [isOn, setIsOn] = useState(false);
<Toggle isOn={isOn} onToggle={() => setIsOn(!isOn)} />

Flexible component — supports both modes:

jsx
function Toggle({ isOn: controlledIsOn, onToggle, defaultOn = false }) {
  const [internalIsOn, setInternalIsOn] = React.useState(defaultOn);
  const isControlled = controlledIsOn !== undefined;
  const isOn = isControlled ? controlledIsOn : internalIsOn;

  const handleToggle = () => {
    if (!isControlled) setInternalIsOn(!isOn);
    onToggle?.(!isOn);
  };

  return (
    <button onClick={handleToggle}>
      {isOn ? 'ON' : 'OFF'}
    </button>
  );
}

This dual-mode pattern is used by many UI libraries. Native <input> elements in React follow the same idea: passing value makes it controlled, while using defaultValue leaves it uncontrolled.

Rule of thumb: Make components controlled when the parent needs to synchronize or validate state. Use uncontrolled when the component is self-contained.

Compound Tabs Component

html
<div id="root"></div>
<script src="https://unpkg.com/react@19/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@19/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
  const TabsContext = React.createContext();

  function Tabs({ children, defaultIndex = 0 }) {
    const [activeIndex, setActiveIndex] = React.useState(defaultIndex);
    return (
      <TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
        <div id="tabs">{children}</div>
      </TabsContext.Provider>
    );
  }

  function TabList({ children }) {
    return <div role="tablist" style={{ display: "flex", gap: "4px" }}>{children}</div>;
  }

  function Tab({ index, children }) {
    const { activeIndex, setActiveIndex } = React.useContext(TabsContext);
    return (
      <button
        className={index === activeIndex ? "active" : ""}
        onClick={() => setActiveIndex(index)}
        style={{
          padding: "8px 16px",
          background: index === activeIndex ? "#646cff" : "#333",
          color: "#fff",
          border: "none",
          borderRadius: "4px 4px 0 0",
          cursor: "pointer"
        }}
      >
        {children}
      </button>
    );
  }

  function TabPanel({ index, children }) {
    const { activeIndex } = React.useContext(TabsContext);
    if (index !== activeIndex) return null;
    return <div id="active-panel" style={{ padding: "16px", background: "#1a1a2e", borderRadius: "0 4px 4px 4px" }}>{children}</div>;
  }

  function App() {
    return (
      <Tabs>
        <TabList>
          <Tab index={0}>Tab 1</Tab>
          <Tab index={1}>Tab 2</Tab>
        </TabList>
        <TabPanel index={0}>Content 1</TabPanel>
        <TabPanel index={1}>Content 2</TabPanel>
      </Tabs>
    );
  }

  ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>

Which pattern has largely replaced render props and HOCs for sharing stateful logic?

Ready to practice?

Create your free account to access the interactive code editor, run challenges, and track your progress.