TechLead

React Patterns

Compound components, render props, HOCs, and advanced custom hook patterns

React Design Patterns

As React applications grow, repeating logic across components becomes a problem. React patterns are proven solutions for sharing behavior, creating flexible APIs, and keeping code maintainable. Let's explore the most important patterns used in production.

Compound Components

Compound components share implicit state among a group of related components. They give users full control over rendering while keeping the logic encapsulated. Think of <select> and <option> in HTML.

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

// Shared context
const AccordionContext = createContext();

function Accordion({ children, defaultOpen = null }) {
  const [openItem, setOpenItem] = useState(defaultOpen);

  return (
    <AccordionContext.Provider value={{ openItem, setOpenItem }}>
      <div className="border rounded-lg divide-y">{children}</div>
    </AccordionContext.Provider>
  );
}

function AccordionItem({ id, children }) {
  return <div>{children}</div>;
}

function AccordionTrigger({ id, children }) {
  const { openItem, setOpenItem } = useContext(AccordionContext);
  const isOpen = openItem === id;

  return (
    <button
      onClick={() => setOpenItem(isOpen ? null : id)}
      className="w-full p-4 text-left flex justify-between items-center"
    >
      {children}
      <span>{isOpen ? '▲' : '▼'}</span>
    </button>
  );
}

function AccordionContent({ id, children }) {
  const { openItem } = useContext(AccordionContext);
  if (openItem !== id) return null;

  return <div className="p-4 bg-gray-50">{children}</div>;
}

// Attach sub-components
Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;

// Usage — clean, flexible API
function FAQ() {
  return (
    <Accordion defaultOpen="q1">
      <Accordion.Item id="q1">
        <Accordion.Trigger id="q1">What is React?</Accordion.Trigger>
        <Accordion.Content id="q1">React is a UI library.</Accordion.Content>
      </Accordion.Item>
      <Accordion.Item id="q2">
        <Accordion.Trigger id="q2">Why use React?</Accordion.Trigger>
        <Accordion.Content id="q2">Component model, ecosystem.</Accordion.Content>
      </Accordion.Item>
    </Accordion>
  );
}

Custom Hooks for Shared Logic

The modern replacement for HOCs and render props — extract reusable stateful logic into custom hooks:

// useLocalStorage.js — Persist state to localStorage
import { useState, useEffect } from 'react';

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const saved = localStorage.getItem(key);
      return saved !== null ? JSON.parse(saved) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// useMediaQuery.js — Responsive breakpoints in JS
export function useMediaQuery(query) {
  const [matches, setMatches] = useState(false);

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

  return matches;
}

// useDebounce.js — Debounce rapidly changing values
export function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debounced;
}

// Usage
function SearchPage() {
  const [query, setQuery] = useLocalStorage('search', '');
  const debouncedQuery = useDebounce(query, 500);
  const isMobile = useMediaQuery('(max-width: 768px)');

  useEffect(() => {
    if (debouncedQuery) fetchResults(debouncedQuery);
  }, [debouncedQuery]);

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {isMobile ? <MobileResults /> : <DesktopResults />}
    </div>
  );
}

Render Props Pattern

A component receives a function as a prop and calls it with data — gives full rendering control to the consumer:

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

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

  return render(position);
}

// Usage — you decide how to display the data
function App() {
  return (
    <div>
      <MousePosition
        render={({ x, y }) => (
          <p>Mouse is at ({x}, {y})</p>
        )}
      />

      <MousePosition
        render={({ x, y }) => (
          <div
            className="w-4 h-4 bg-red-500 rounded-full fixed"
            style={{ left: x, top: y }}
          />
        )}
      />
    </div>
  );
}

// Modern alternative: use a custom hook instead
function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  useEffect(() => {
    const handleMove = (e) => setPosition({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', handleMove);
    return () => window.removeEventListener('mousemove', handleMove);
  }, []);
  return position;
}

Higher-Order Components (HOC)

A function that takes a component and returns a new component with enhanced behavior. Still used in some libraries:

// withAuth HOC — protects routes
function withAuth(WrappedComponent) {
  return function AuthenticatedComponent(props) {
    const { user, loading } = useAuth();

    if (loading) return <LoadingSpinner />;
    if (!user) return <Navigate to="/login" />;

    return <WrappedComponent {...props} user={user} />;
  };
}

// Usage
const ProtectedDashboard = withAuth(Dashboard);
const ProtectedSettings = withAuth(Settings);

// In routes
<Route path="/dashboard" element={<ProtectedDashboard />} />
<Route path="/settings" element={<ProtectedSettings />} />

Container / Presentational Pattern

Separate data fetching (container) from rendering (presentational) for cleaner code:

// Container — handles data logic
function UserListContainer() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [filter, setFilter] = useState('');

  useEffect(() => {
    fetchUsers().then(data => { setUsers(data); setLoading(false); });
  }, []);

  const filteredUsers = users.filter(u =>
    u.name.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <UserList
      users={filteredUsers}
      loading={loading}
      filter={filter}
      onFilterChange={setFilter}
    />
  );
}

// Presentational — pure rendering, easy to test
function UserList({ users, loading, filter, onFilterChange }) {
  if (loading) return <Skeleton />;

  return (
    <div>
      <input
        value={filter}
        onChange={e => onFilterChange(e.target.value)}
        placeholder="Search users..."
      />
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
        {users.length === 0 && <li>No users found</li>}
      </ul>
    </div>
  );
}

Controlled vs Uncontrolled Components

Build components that work in both controlled (parent manages state) and uncontrolled (self-managed) modes:

function Toggle({ value: controlledValue, onChange, defaultValue = false }) {
  // Internal state (uncontrolled mode)
  const [internalValue, setInternalValue] = useState(defaultValue);

  // Use controlled value if provided, otherwise internal
  const isControlled = controlledValue !== undefined;
  const isOn = isControlled ? controlledValue : internalValue;

  function handleToggle() {
    if (isControlled) {
      onChange?.(!controlledValue);
    } else {
      setInternalValue(prev => {
        onChange?.(!prev);
        return !prev;
      });
    }
  }

  return (
    <button
      onClick={handleToggle}
      className={`px-4 py-2 rounded ${isOn ? 'bg-blue-500 text-white' : 'bg-gray-200'}`}
    >
      {isOn ? 'ON' : 'OFF'}
    </button>
  );
}

// Uncontrolled usage
<Toggle defaultValue={true} onChange={v => console.log(v)} />

// Controlled usage
const [darkMode, setDarkMode] = useState(false);
<Toggle value={darkMode} onChange={setDarkMode} />

💡 When to Use Which Pattern

  • Custom Hooks: First choice for sharing stateful logic (replaces most HOCs and render props)
  • Compound Components: Building component libraries with flexible APIs (tabs, dropdowns, accordions)
  • Render Props: When a hook isn't sufficient and you need rendering flexibility
  • HOCs: Cross-cutting concerns like auth guards, logging, analytics wrappers
  • Container/Presentational: Clean separation of data and UI in complex features