Skip to content
advanced Phase 8 · React Advanced

React Design Patterns

Apply compound components, render props, higher-order components, and the container/presentational pattern.

1h 15m
0 problems
Topic Progress 0%

Compound Components

Compound Components

Implicit State Sharing via Context

Compound components allow a parent component to implicitly share state with its children through React Context, eliminating the need for prop drilling. The parent owns the state and exposes it to children without requiring the consumer to wire up props manually.

import { createContext, useContext, useState, useCallback } from 'react';

const TabsContext = createContext(null);

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

  const selectTab = useCallback((index) => {
    setActiveIndex(index);
    onChange?.(index);
  }, [onChange]);

  return (
    <TabsContext.Provider value={{ activeIndex, selectTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

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

function Tab({ index, children }) {
  const { activeIndex, selectTab } = useContext(TabsContext);
  return (
    <button
      role="tab"
      aria-selected={activeIndex === index}
      onClick={() => selectTab(index)}
      className={activeIndex === index ? 'tab active' : 'tab'}
    >
      {children}
    </button>
  );
}

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

// Usage — clean, declarative API
<Tabs onChange={(i) => console.log('Tab', i)}>
  <TabList>
    <Tab index={0}>Profile</Tab>
    <Tab index={1}>Settings</Tab>
    <Tab index={2}>Notifications</Tab>
  </TabList>
  <TabPanel index={0}>Profile content</TabPanel>
  <TabPanel index={1}>Settings content</TabPanel>
  <TabPanel index={2}>Notifications content</TabPanel>
</Tabs>

Why Compound Components?

The parent controls the shared state so consumers do not need to manage or pass it down. Adding new tabs requires zero changes to the parent — you just compose new Tab and TabPanel children. This pattern produces clean, declarative APIs that are easy to extend without breaking existing usage.

// Accordion compound component
const AccordionContext = createContext(null);

function Accordion({ allowMultiple = false, children }) {
  const [openItems, setOpenItems] = useState(new Set());

  const toggle = useCallback((id) => {
    setOpenItems(prev => {
      const next = new Set(allowMultiple ? prev : []);
      if (prev.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return next;
    });
  }, [allowMultiple]);

  return (
    <AccordionContext.Provider value={{ openItems, toggle }}>
      <div className="accordion">{children}</div>
    </AccordionContext.Provider>
  );
}

function AccordionItem({ id, children }) {
  return <div className="accordion-item">{children}</div>;
}

function AccordionHeader({ id, children }) {
  const { openItems, toggle } = useContext(AccordionContext);
  return (
    <button
      onClick={() => toggle(id)}
      aria-expanded={openItems.has(id)}
    >
      {children}
    </button>
  );
}

function AccordionPanel({ id, children }) {
  const { openItems } = useContext(AccordionContext);
  if (!openItems.has(id)) return null;
  return <div className="accordion-panel">{children}</div>;
}

Render Props and HOCs

Render Props and HOCs

Render Props Pattern

A render prop is a function prop that a component calls instead of rendering its own markup. The parent supplies the rendering logic, giving full control over what and how content is rendered while the component manages the stateful logic.

import { useState, useEffect } from 'react';

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

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

  return position;
}

// Render prop component
function MouseTracker({ render }) {
  const { x, y } = useMousePosition();
  return render({ x, y });
}

// Usage
<MouseTracker
  render={({ x, y }) => (
    <div>
      <p>Mouse is at ({x}, {y})</p>
      <div
        style={{
          width: 20,
          height: 20,
          borderRadius: '50%',
          background: 'red',
          position: 'fixed',
          left: x - 10,
          top: y - 10,
        }}
      />
    </div>
  )}
/>

Higher-Order Components (HOCs)

A HOC is a function that takes a component and returns a new component with enhanced behavior. HOCs wrap a component to inject props, add state, or modify rendering logic. They were the original pattern for code reuse before hooks existed.

function withLoading(WrappedComponent, LoadingComponent) {
  return function WithLoadingComponent({ isLoading, ...props }) {
    if (isLoading) return <LoadingComponent />;
    return <WrappedComponent {...props} />;
  };
}

function UserProfile({ user }) {
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

const UserProfileWithLoading = withLoading(UserProfile, Spinner);

// Usage
<UserProfileWithLoading isLoading={loading} user={user} />

Combining Patterns

HOCs and render props serve similar purposes — they let you reuse stateful logic across components. Render props offer more composability (you can nest multiple render prop components), while HOCs provide cleaner usage syntax at the component level. Modern React favors custom hooks for most cases, but these patterns remain useful when you need to wrap a component tree or inject props into class components.

// HOC that adds authentication context
function withAuth(WrappedComponent) {
  return function AuthenticatedComponent(props) {
    const { user, loading } = useAuth();

    if (loading) return <Spinner />;
    if (!user) return <Navigate to="/login" />;
    return <WrappedComponent {...props} user={user} />;
  };
}

// Render prop for toggling visibility
function Toggle({ initial = false, children }) {
  const [on, setOn] = useState(initial);
  const toggle = () => setOn(prev => !prev);
  return children({ on, toggle });
}

// Combining render props
<Toggle initial={true}>
  {({ on, toggle }) => (
    <div>
      <button onClick={toggle}>{on ? 'Hide' : 'Show'}</button>
      {on && <DropdownMenu items={items} />}
    </div>
  )}
</Toggle>

Container and Presentational Components

Container and Presentational Components

Separating Data from UI

The container/presentational pattern splits components into two categories: containers handle data fetching, state management, and business logic; presentational components receive data via props and focus purely on rendering UI. This separation makes components easier to test, reuse, and reason about.

// Presentational component — receives data, renders UI
function UserListUI({ users, loading, error, onRetry }) {
  if (loading) return <div className="skeleton-list">Loading...</div>;
  if (error) {
    return (
      <div className="error-state">
        <p>Failed to load users: {error}</p>
        <button onClick={onRetry}>Retry</button>
      </div>
    );
  }
  return (
    <ul className="user-list">
      {users.map(user => (
        <li key={user.id} className="user-item">
          <img src={user.avatar} alt={user.name} />
          <div>
            <strong>{user.name}</strong>
            <span>{user.email}</span>
          </div>
        </li>
      ))}
    </ul>
  );
}

// Container component — handles logic, delegates rendering
function UserListContainer() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const fetchUsers = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const res = await fetch('/api/users');
      if (!res.ok) throw new Error('Failed to fetch');
      const data = await res.json();
      setUsers(data);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => { fetchUsers(); }, [fetchUsers]);

  return (
    <UserListUI
      users={users}
      loading={loading}
      error={error}
      onRetry={fetchUsers}
    />
  );
}

When to Use This Pattern

Use the container/presentational split when a component does too many things — fetching data, managing state, and rendering complex UI. Extract the data logic into a container and keep presentational components pure functions of their props. This makes presentational components trivially testable (just pass props, assert output) and reusable in different contexts. With custom hooks, you can extract the container logic into a hook and keep the component simple.

// Extracting container logic into a custom hook
function useUsers() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const fetchUsers = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const res = await fetch('/api/users');
      if (!res.ok) throw new Error('Failed to fetch');
      setUsers(await res.json());
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => { fetchUsers(); }, [fetchUsers]);
  return { users, loading, error, retry: fetchUsers };
}

// Now the component is simple — hook does the heavy lifting
function UserList() {
  const { users, loading, error, retry } = useUsers();
  return <UserListUI users={users} loading={loading} error={error} onRetry={retry} />;
}

Custom Hooks for Shared Logic

Custom Hooks for Shared Logic

Extracting Reusable Stateful Logic

Custom hooks are the modern replacement for HOCs and render props. They let you extract component logic into reusable functions that follow the rules of hooks. A custom hook can use state, effects, context, and other hooks internally while exposing a clean API to consumers.

import { useState, useEffect, useCallback } from 'react';

// useToggle — simple state machine
function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(v => !v), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);
  return { value, toggle, setTrue, setFalse };
}

// Usage
function Modal() {
  const { value: isOpen, toggle, setFalse: close } = useToggle();
  return (
    <>
      <button onClick={toggle}>Open Modal</button>
      {isOpen && (
        <div className="modal-overlay">
          <div className="modal">
            <h2>Modal Title</h2>
            <p>Modal content goes here</p>
            <button onClick={close}>Close</button>
          </div>
        </div>
      )}
    </>
  );
}

useMediaQuery — Reactive Breakpoints

function useMediaQuery(query) {
  const [matches, setMatches] = useState(
    () => typeof window !== 'undefined' && window.matchMedia(query).matches
  );

  useEffect(() => {
    const mq = window.matchMedia(query);
    const handler = (e) => setMatches(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, [query]);

  return matches;
}

// Usage
function ResponsiveLayout({ mobile, desktop }) {
  const isDesktop = useMediaQuery('(min-width: 768px)');
  return isDesktop ? desktop : mobile;
}

useLocalStorage — Persistent State

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = useCallback((value) => {
    setStoredValue(prev => {
      const valueToStore = value instanceof Function ? value(prev) : value;
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
      return valueToStore;
    });
  }, [key]);

  return [storedValue, setValue];
}

// Usage
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  return (
    <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
      Current theme: {theme}
    </button>
  );
}

useAnimation — Spring-based Animations

function useAnimation(duration = 300) {
  const [progress, setProgress] = useState(0);
  const [isAnimating, setIsAnimating] = useState(false);

  const animate = useCallback(() => {
    setIsAnimating(true);
    setProgress(0);
    const start = performance.now();

    const frame = (now) => {
      const elapsed = now - start;
      const t = Math.min(elapsed / duration, 1);
      setProgress(t);
      if (t < 1) {
        requestAnimationFrame(frame);
      } else {
        setIsAnimating(false);
      }
    };

    requestAnimationFrame(frame);
  }, [duration]);

  return { progress, isAnimating, animate };
}

// Usage
function AnimatedBar() {
  const { progress, animate } = useAnimation(500);
  return (
    <div>
      <button onClick={animate}>Animate</button>
      <div
        style={{
          width: `${progress * 100}%`,
          height: 20,
          background: 'blue',
          transition: 'none',
        }}
      />
    </div>
  );
}

Quiz

1. What problem do compound components solve compared to passing many props to a single component?

Question 1 options

2. How do render props and higher-order components differ in how they share logic?

Question 2 options

3. Why is the container/presentational pattern beneficial for testing?

Question 3 options

Flashcards

Question

What are compound components and when should you use them?

Answer

Compound components share implicit state via React Context so a parent controls shared behavior while children compose freely. Use them when building UI primitives where multiple related components need to coordinate state (tabs, accordions, dropdowns, menus). They produce clean declarative APIs and eliminate prop drilling within a component tree.

Question

Explain the render prop pattern with an example.

Answer

A render prop is a function prop that a component calls with its internal state, letting the consumer control rendering. Example: `<MouseTracker render={({x, y}) => <p>Mouse at {x}, {y}</p>} />`. The component owns the state logic (tracking mouse position) and the consumer decides how to render it. This separates concerns and allows reuse without inheritance.

Question

How do custom hooks replace HOCs and render props?

Answer

Custom hooks extract stateful logic into reusable functions that follow the rules of hooks. Unlike HOCs (which wrap components and inject props) or render props (which nest function children), hooks compose naturally at the top of a component. They avoid wrapper hell, have clearer naming, and work with any component. Example: `const { data, loading } = useFetch(url)` replaces a `withFetch` HOC entirely.

Revision Notes

Key Takeaways

  • 1. Compound components use Context to share implicit state between parent and children, producing declarative APIs that are easy to extend without breaking existing usage
  • 2. Render props pass a function that receives component state — the consumer controls rendering while the component manages logic; HOCs wrap components and inject enhanced props
  • 3. Container/presentational split separates data fetching and state management (container) from UI rendering (presentational), making components easier to test and reuse
  • 4. Custom hooks are the modern replacement for HOCs and render props — they extract stateful logic into composable functions without wrapper components
  • 5. useToggle, useLocalStorage, useMediaQuery, and useAnimation are practical custom hook patterns that encapsulate common UI behaviors
  • 6. HOCs remain useful for cross-cutting concerns like authentication and error boundaries where you need to wrap a component tree
  • 7. When building compound components, always expose clean prop APIs and use Context only for implicit shared state, not for passing everything

Interview Tips

  • Build a Tabs compound component from scratch using Context — explain how implicit state sharing eliminates prop drilling
  • Compare render props vs HOCs vs custom hooks with concrete examples of when each is the right choice
  • Explain the container/presentational pattern and demonstrate how to refactor a bloated component into two focused pieces
  • Describe how you would build a useFetch hook that handles loading, error, abort, and cleanup — walk through the implementation
  • Discuss when HOCs are still preferred over hooks (error boundaries, class component wrappers, cross-cutting concerns)

Cheat Sheet

Compound Components

Parent owns state via Context; children read shared state implicitly — clean declarative API without prop drilling

Render Props

Component calls props.render(state) or props.children(state) — consumer controls rendering, component owns logic

HOC

const Enhanced = withHOC(WrappedComponent) — injects props, adds behavior, wraps component tree

Container

Handles data fetching, state, business logic — delegates rendering to presentational child

Presentational

Pure function of props — renders UI, no side effects, trivially testable

Custom Hooks

Function starting with use that encapsulates reusable stateful logic — modern replacement for HOCs and render props

useToggle

Simple boolean state with toggle/setTrue/setFalse — replaces manual useState+toggle pattern

useLocalStorage

useState that persists to localStorage — lazy initialization, JSON serialization, cross-tab sync

useMediaQuery

Reactive window.matchMedia — returns boolean for responsive breakpoints, updates on resize