Advanced React Patterns: Compound Components and Render Props
1/5/2024
12 min read
Tridip Dutta
Web Development

Advanced React Patterns: Compound Components and Render Props

Master advanced React patterns including compound components, render props, and custom hooks for building flexible and reusable component libraries.

React
Design Patterns
Component Architecture
Frontend

Advanced React Patterns: Compound Components and Render Props

As React applications grow in complexity, the need for flexible, reusable, and maintainable component patterns becomes crucial. In this guide, we'll explore advanced React patterns that help you build robust component libraries and create more flexible APIs for your components.

Compound Components Pattern

The Compound Components pattern allows you to create components that work together to form a complete UI element, similar to how HTML elements like <select> and <option> work together.

Basic Compound Component Example

// Traditional approach - less flexible
function Dropdown({ options, onSelect }) {
  return (
    <div className="dropdown">
      <button>Select an option</button>
      <ul>
        {options.map(option => (
          <li key={option.value} onClick={() => onSelect(option)}>
            {option.label}
          </li>
        ))}
      </ul>
    </div>
  );
}

// Compound component approach - more flexible
function Dropdown({ children }) {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <div className="dropdown">
      {React.Children.map(children, child =>
        React.cloneElement(child, { isOpen, setIsOpen })
      )}
    </div>
  );
}

function DropdownTrigger({ children, isOpen, setIsOpen }) {
  return (
    <button onClick={() => setIsOpen(!isOpen)}>
      {children}
    </button>
  );
}

function DropdownMenu({ children, isOpen }) {
  if (!isOpen) return null;
  
  return (
    <ul className="dropdown-menu">
      {children}
    </ul>
  );
}

function DropdownItem({ children, onSelect }) {
  return (
    <li onClick={onSelect}>
      {children}
    </li>
  );
}

// Usage
function App() {
  return (
    <Dropdown>
      <DropdownTrigger>Select an option</DropdownTrigger>
      <DropdownMenu>
        <DropdownItem onSelect={() => console.log('Option 1')}>
          Option 1
        </DropdownItem>
        <DropdownItem onSelect={() => console.log('Option 2')}>
          Option 2
        </DropdownItem>
      </DropdownMenu>
    </Dropdown>
  );
}

Advanced Compound Components with Context

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

// Create context for sharing state
const DropdownContext = createContext();

function useDropdownContext() {
  const context = useContext(DropdownContext);
  if (!context) {
    throw new Error('Dropdown components must be used within Dropdown');
  }
  return context;
}

function Dropdown({ children, onSelectionChange }) {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedValue, setSelectedValue] = useState(null);
  
  const selectValue = (value) => {
    setSelectedValue(value);
    setIsOpen(false);
    onSelectionChange?.(value);
  };
  
  const value = {
    isOpen,
    setIsOpen,
    selectedValue,
    selectValue,
  };
  
  return (
    <DropdownContext.Provider value={value}>
      <div className="dropdown">
        {children}
      </div>
    </DropdownContext.Provider>
  );
}

function DropdownTrigger({ children }) {
  const { isOpen, setIsOpen, selectedValue } = useDropdownContext();
  
  return (
    <button 
      className="dropdown-trigger"
      onClick={() => setIsOpen(!isOpen)}
    >
      {selectedValue || children}
    </button>
  );
}

function DropdownMenu({ children }) {
  const { isOpen } = useDropdownContext();
  
  if (!isOpen) return null;
  
  return (
    <ul className="dropdown-menu">
      {children}
    </ul>
  );
}

function DropdownItem({ children, value }) {
  const { selectValue } = useDropdownContext();
  
  return (
    <li 
      className="dropdown-item"
      onClick={() => selectValue(value)}
    >
      {children}
    </li>
  );
}

// Attach sub-components to main component
Dropdown.Trigger = DropdownTrigger;
Dropdown.Menu = DropdownMenu;
Dropdown.Item = DropdownItem;

// Usage
function App() {
  return (
    <Dropdown onSelectionChange={(value) => console.log('Selected:', value)}>
      <Dropdown.Trigger>Choose a fruit</Dropdown.Trigger>
      <Dropdown.Menu>
        <Dropdown.Item value="apple">🍎 Apple</Dropdown.Item>
        <Dropdown.Item value="banana">🍌 Banana</Dropdown.Item>
        <Dropdown.Item value="orange">🍊 Orange</Dropdown.Item>
      </Dropdown.Menu>
    </Dropdown>
  );
}

Render Props Pattern

The Render Props pattern involves passing a function as a prop to share code between components. This function returns a React element and receives data as arguments.

Basic Render Props Example

function DataFetcher({ url, render }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    fetch(url)
      .then(response => response.json())
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(error => {
        setError(error);
        setLoading(false);
      });
  }, [url]);
  
  return render({ data, loading, error });
}

// Usage
function UserProfile({ userId }) {
  return (
    <DataFetcher
      url={`/api/users/${userId}`}
      render={({ data, loading, error }) => {
        if (loading) return <div>Loading...</div>;
        if (error) return <div>Error: {error.message}</div>;
        if (!data) return <div>No user found</div>;
        
        return (
          <div>
            <h1>{data.name}</h1>
            <p>{data.email}</p>
          </div>
        );
      }}
    />
  );
}

Children as Function (Render Props Variant)

function MouseTracker({ children }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  
  useEffect(() => {
    const handleMouseMove = (event) => {
      setPosition({ x: event.clientX, y: event.clientY });
    };
    
    window.addEventListener('mousemove', handleMouseMove);
    return () => window.removeEventListener('mousemove', handleMouseMove);
  }, []);
  
  return children(position);
}

// Usage
function App() {
  return (
    <MouseTracker>
      {({ x, y }) => (
        <div>
          <h1>Mouse position: ({x}, {y})</h1>
          <div 
            style={{
              position: 'absolute',
              left: x,
              top: y,
              width: 10,
              height: 10,
              backgroundColor: 'red',
              borderRadius: '50%',
            }}
          />
        </div>
      )}
    </MouseTracker>
  );
}

Custom Hooks Pattern

Custom hooks are the modern way to share stateful logic between components, often replacing render props and higher-order components.

Converting Render Props to Custom Hooks

// Custom hook version of DataFetcher
function useApi(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    if (!url) return;
    
    setLoading(true);
    setError(null);
    
    fetch(url)
      .then(response => {
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        return response.json();
      })
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(error => {
        setError(error);
        setLoading(false);
      });
  }, [url]);
  
  return { data, loading, error };
}

// Usage
function UserProfile({ userId }) {
  const { data: user, loading, error } = useApi(`/api/users/${userId}`);
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return <div>No user found</div>;
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

Advanced Custom Hook with Caching

function useApiWithCache(url, options = {}) {
  const { cacheTime = 5 * 60 * 1000 } = options; // 5 minutes default
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  // Simple in-memory cache
  const cache = useRef(new Map());
  
  useEffect(() => {
    if (!url) return;
    
    const cachedData = cache.current.get(url);
    const now = Date.now();
    
    // Check if we have valid cached data
    if (cachedData && (now - cachedData.timestamp) < cacheTime) {
      setData(cachedData.data);
      setLoading(false);
      return;
    }
    
    setLoading(true);
    setError(null);
    
    fetch(url)
      .then(response => response.json())
      .then(data => {
        // Cache the data
        cache.current.set(url, {
          data,
          timestamp: now,
        });
        
        setData(data);
        setLoading(false);
      })
      .catch(error => {
        setError(error);
        setLoading(false);
      });
  }, [url, cacheTime]);
  
  const invalidateCache = useCallback((urlToInvalidate) => {
    if (urlToInvalidate) {
      cache.current.delete(urlToInvalidate);
    } else {
      cache.current.clear();
    }
  }, []);
  
  return { data, loading, error, invalidateCache };
}

Higher-Order Components (HOCs)

While less common in modern React, HOCs are still useful for certain scenarios, especially when working with class components or third-party libraries.

Basic HOC Example

function withLoading(WrappedComponent) {
  return function WithLoadingComponent(props) {
    if (props.loading) {
      return <div>Loading...</div>;
    }
    
    return <WrappedComponent {...props} />;
  };
}

// Usage
const UserProfileWithLoading = withLoading(UserProfile);

function App() {
  const { data, loading } = useApi('/api/user');
  
  return (
    <UserProfileWithLoading 
      user={data} 
      loading={loading} 
    />
  );
}

HOC with Configuration

function withAuth(requiredRole = null) {
  return function(WrappedComponent) {
    return function WithAuthComponent(props) {
      const { user, loading } = useAuth();
      
      if (loading) {
        return <div>Checking authentication...</div>;
      }
      
      if (!user) {
        return <div>Please log in to access this page.</div>;
      }
      
      if (requiredRole && user.role !== requiredRole) {
        return <div>You don't have permission to access this page.</div>;
      }
      
      return <WrappedComponent {...props} user={user} />;
    };
  };
}

// Usage
const AdminPanel = withAuth('admin')(AdminPanelComponent);
const UserDashboard = withAuth()(UserDashboardComponent);

Combining Patterns

Often, the most powerful solutions come from combining multiple patterns:

// Compound component with custom hooks
function useAccordion() {
  const [openItems, setOpenItems] = useState(new Set());
  
  const toggleItem = useCallback((id) => {
    setOpenItems(prev => {
      const newSet = new Set(prev);
      if (newSet.has(id)) {
        newSet.delete(id);
      } else {
        newSet.add(id);
      }
      return newSet;
    });
  }, []);
  
  const isOpen = useCallback((id) => openItems.has(id), [openItems]);
  
  return { toggleItem, isOpen };
}

const AccordionContext = createContext();

function Accordion({ children, allowMultiple = false }) {
  const [openItems, setOpenItems] = useState(new Set());
  
  const toggleItem = useCallback((id) => {
    setOpenItems(prev => {
      const newSet = new Set(allowMultiple ? prev : []);
      if (prev.has(id)) {
        newSet.delete(id);
      } else {
        newSet.add(id);
      }
      return newSet;
    });
  }, [allowMultiple]);
  
  const isOpen = useCallback((id) => openItems.has(id), [openItems]);
  
  return (
    <AccordionContext.Provider value={{ toggleItem, isOpen }}>
      <div className="accordion">
        {children}
      </div>
    </AccordionContext.Provider>
  );
}

function AccordionItem({ id, children }) {
  const { toggleItem, isOpen } = useContext(AccordionContext);
  
  return (
    <div className="accordion-item">
      {React.Children.map(children, child =>
        React.cloneElement(child, { id, toggleItem, isOpen: isOpen(id) })
      )}
    </div>
  );
}

function AccordionHeader({ children, id, toggleItem }) {
  return (
    <button 
      className="accordion-header"
      onClick={() => toggleItem(id)}
    >
      {children}
    </button>
  );
}

function AccordionPanel({ children, isOpen }) {
  return isOpen ? (
    <div className="accordion-panel">
      {children}
    </div>
  ) : null;
}

Accordion.Item = AccordionItem;
Accordion.Header = AccordionHeader;
Accordion.Panel = AccordionPanel;

// Usage
function App() {
  return (
    <Accordion allowMultiple>
      <Accordion.Item id="item1">
        <Accordion.Header>Section 1</Accordion.Header>
        <Accordion.Panel>Content for section 1</Accordion.Panel>
      </Accordion.Item>
      <Accordion.Item id="item2">
        <Accordion.Header>Section 2</Accordion.Header>
        <Accordion.Panel>Content for section 2</Accordion.Panel>
      </Accordion.Item>
    </Accordion>
  );
}

Best Practices and When to Use Each Pattern

Compound Components:

  • ✅ When you need flexible, composable APIs
  • ✅ For building design systems and component libraries
  • ✅ When components have a natural parent-child relationship

Render Props:

  • ✅ When you need to share stateful logic
  • ✅ For maximum flexibility in rendering
  • ❌ Can lead to "wrapper hell" with multiple render props

Custom Hooks:

  • ✅ Modern replacement for most render props use cases
  • ✅ Cleaner, more reusable code
  • ✅ Better composition and testing

HOCs:

  • ✅ When working with class components
  • ✅ For cross-cutting concerns (auth, logging, etc.)
  • ❌ Can make debugging harder
  • ❌ Props collision issues

Conclusion

Advanced React patterns provide powerful tools for building flexible, reusable, and maintainable component libraries. Start with custom hooks for sharing logic, use compound components for flexible APIs, and consider render props when you need maximum flexibility.

The key is to choose the right pattern for your specific use case and not over-engineer simple solutions. These patterns shine when building reusable component libraries or solving complex state sharing problems.

Resources


Mastering these patterns will make you a more effective React developer and enable you to build more flexible and maintainable applications. Practice implementing these patterns in your own projects to truly understand their power.

TD

About Tridip Dutta

Creative Developer passionate about creating innovative digital experiences and exploring AI. I love sharing knowledge to help developers build better apps.