Reactadvanced

React Patterns & Best Practices

Master advanced React patterns: compound components, render props, HOCs, container/presentational split, and props drilling solutions. Know when to use each.

13 min readยทPublished Mar 24, 2026
reactpatternsbest-practicesarchitecture

Why Patterns Matter

React gives you primitives: components, props, state, context, hooks. Patterns are the proven ways to combine these primitives to solve recurring problems โ€” sharing logic, managing complex state, building flexible APIs, and organizing large codebases.

No pattern is universally better than another. Each has trade-offs. The skill is recognizing which problem you have and picking the right tool.

Problem                              --> Pattern
------                                   -------
Shared stateful logic                --> Custom Hooks
Flexible component API               --> Compound Components
Injecting behavior into components   --> HOCs or Render Props
Passing data through many layers     --> Context / Composition
Separating logic from display        --> Container / Presentational
Reusable rendering logic             --> Render Props

Compound Components

Compound components are a set of components that work together to form a complete UI element. The parent manages shared state; the children consume it implicitly. Think of <select> and <option> โ€” they are useless alone but powerful together.

Basic Example: Accordion

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

const AccordionContext = createContext(null);

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

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

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

function AccordionItem({ id, children }) {
  const { openItems, toggle } = useContext(AccordionContext);
  const isOpen = openItems.has(id);

  return (
    <div className="accordion-item">
      {typeof children === 'function'
        ? children({ isOpen, toggle: () => toggle(id) })
        : children}
    </div>
  );
}

function AccordionHeader({ id, children }) {
  const { openItems, toggle } = useContext(AccordionContext);
  const isOpen = openItems.has(id);

  return (
    <button
      className="accordion-header"
      onClick={() => toggle(id)}
      aria-expanded={isOpen}
    >
      {children}
      <span>{isOpen ? 'โˆ’' : '+'}</span>
    </button>
  );
}

function AccordionPanel({ id, children }) {
  const { openItems } = useContext(AccordionContext);
  if (!openItems.has(id)) return null;

  return <div className="accordion-panel">{children}</div>;
}

// Attach sub-components
Accordion.Item = AccordionItem;
Accordion.Header = AccordionHeader;
Accordion.Panel = AccordionPanel;

Usage:

function FAQ() {
  return (
    <Accordion allowMultiple>
      <Accordion.Item id="q1">
        <Accordion.Header id="q1">What is React?</Accordion.Header>
        <Accordion.Panel id="q1">
          A JavaScript library for building user interfaces.
        </Accordion.Panel>
      </Accordion.Item>

      <Accordion.Item id="q2">
        <Accordion.Header id="q2">What are hooks?</Accordion.Header>
        <Accordion.Panel id="q2">
          Functions that let you use state and lifecycle in function components.
        </Accordion.Panel>
      </Accordion.Item>
    </Accordion>
  );
}

The consumer does not manage open/close state. They just compose the pieces. The Accordion parent handles everything through context.

Advanced: Tabs Component

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

const TabsContext = createContext(null);

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

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

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

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

  return (
    <button
      role="tab"
      aria-selected={isActive}
      className={`tab ${isActive ? 'tab-active' : ''}`}
      onClick={() => setActiveTab(value)}
    >
      {children}
    </button>
  );
}

function TabPanel({ value, children }) {
  const { activeTab } = useContext(TabsContext);
  if (activeTab !== value) return null;

  return (
    <div role="tabpanel" className="tab-panel">
      {children}
    </div>
  );
}

Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;

Usage:

function SettingsPage() {
  return (
    <Tabs defaultValue="general">
      <Tabs.List>
        <Tabs.Tab value="general">General</Tabs.Tab>
        <Tabs.Tab value="security">Security</Tabs.Tab>
        <Tabs.Tab value="notifications">Notifications</Tabs.Tab>
      </Tabs.List>

      <Tabs.Panel value="general"><GeneralSettings /></Tabs.Panel>
      <Tabs.Panel value="security"><SecuritySettings /></Tabs.Panel>
      <Tabs.Panel value="notifications"><NotificationSettings /></Tabs.Panel>
    </Tabs>
  );
}

Render Props Pattern

A render prop is a function prop that a component uses to know what to render. The component handles the logic; the function handles the UI.

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

  const handleMouseMove = (e) => {
    setPosition({ x: e.clientX, y: e.clientY });
  };

  return (
    <div onMouseMove={handleMouseMove} style={{ height: '100%' }}>
      {render(position)}
    </div>
  );
}

// Usage: the consumer decides how to display the position
function App() {
  return (
    <MouseTracker
      render={({ x, y }) => (
        <p>Mouse is at ({x}, {y})</p>
      )}
    />
  );
}

Children as Render Prop

Using children instead of a named prop is a common variant:

function DataFetcher({ url, children }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(json => { setData(json); setLoading(false); })
      .catch(err => { setError(err); setLoading(false); });
  }, [url]);

  return children({ data, loading, error });
}

// Usage
function UserList() {
  return (
    <DataFetcher url="/api/users">
      {({ data, loading, error }) => {
        if (loading) return <Spinner />;
        if (error) return <Error message={error.message} />;
        return (
          <ul>
            {data.map(user => <li key={user.id}>{user.name}</li>)}
          </ul>
        );
      }}
    </DataFetcher>
  );
}

Toggle Render Prop

function Toggle({ children }) {
  const [on, setOn] = useState(false);
  const toggle = () => setOn(prev => !prev);

  return children({ on, toggle });
}

function App() {
  return (
    <Toggle>
      {({ on, toggle }) => (
        <div>
          <button onClick={toggle}>{on ? 'Hide' : 'Show'}</button>
          {on && <div className="content">Now you see me</div>}
        </div>
      )}
    </Toggle>
  );
}

Render Props vs Custom Hooks

Render props were the dominant logic-sharing pattern before hooks. Today, custom hooks handle most of the same use cases with less indentation:

// Render prop version
<MouseTracker render={({ x, y }) => <Cursor x={x} y={y} />} />

// Hook version
function Component() {
  const { x, y } = useMousePosition();
  return <Cursor x={x} y={y} />;
}

Render props still have value when:

  • You need to conditionally render children based on the logic
  • You are building a library that needs to work without hooks (class components)
  • The rendering logic varies significantly between consumers

Higher-Order Components (HOCs)

A higher-order component is a function that takes a component and returns a new component with additional behavior.

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} />;
  };
}

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

function App() {
  return (
    <Routes>
      <Route path="/dashboard" element={<ProtectedDashboard />} />
      <Route path="/settings" element={<ProtectedSettings />} />
    </Routes>
  );
}

HOC with Configuration

function withLogger(WrappedComponent, componentName) {
  return function LoggedComponent(props) {
    useEffect(() => {
      console.log(`${componentName} mounted`);
      return () => console.log(`${componentName} unmounted`);
    }, []);

    useEffect(() => {
      console.log(`${componentName} updated with props:`, props);
    });

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

const LoggedButton = withLogger(Button, 'Button');

HOC Conventions

function withSubscription(WrappedComponent, selectData) {
  function WithSubscription(props) {
    const [data, setData] = useState(null);

    useEffect(() => {
      const unsubscribe = DataSource.subscribe(() => {
        setData(selectData(DataSource, props));
      });
      return unsubscribe;
    }, [props]);

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

  // Convention 1: Set displayName for DevTools
  const wrappedName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
  WithSubscription.displayName = `withSubscription(${wrappedName})`;

  // Convention 2: Copy static methods (use hoist-non-react-statics)
  // Convention 3: Forward refs
  return WithSubscription;
}

HOC Pitfalls

  1. Wrapper hell: Multiple HOCs create deeply nested component trees
  2. Prop collision: HOCs might inject props that collide with the wrapped component's own props
  3. Ref forwarding: HOCs break refs unless you use forwardRef
  4. Static methods: HOCs do not copy static methods by default
Wrapper hell:
withAuth(withTheme(withLogger(withErrorBoundary(MyComponent))))

DevTools tree:
<WithAuth>
  <WithTheme>
    <WithLogger>
      <WithErrorBoundary>
        <MyComponent />
      </WithErrorBoundary>
    </WithLogger>
  </WithTheme>
</WithAuth>

For new code, prefer custom hooks. HOCs remain useful for cross-cutting concerns in class components or when wrapping third-party components.

Container vs Presentational

This pattern separates components into two categories:

  • Container (smart): handles data fetching, state management, and business logic
  • Presentational (dumb): receives data via props, renders UI, no side effects
// Container: handles logic
function UserListContainer() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [filter, setFilter] = useState('');

  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(data => { setUsers(data); setLoading(false); });
  }, []);

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

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

// Presentational: handles display
function UserList({ users, loading, filter, onFilterChange }) {
  if (loading) return <Spinner />;

  return (
    <div>
      <input
        value={filter}
        onChange={e => onFilterChange(e.target.value)}
        placeholder="Filter users..."
      />
      <ul>
        {users.map(user => (
          <li key={user.id}>
            <img src={user.avatar} alt="" width={32} height={32} />
            <span>{user.name}</span>
            <span className="email">{user.email}</span>
          </li>
        ))}
      </ul>
      {users.length === 0 && <p>No users match your filter.</p>}
    </div>
  );
}

Benefits:

  • Presentational components are easy to test (just props in, JSX out)
  • They are reusable โ€” same UserList can be used with different data sources
  • Clear separation of concerns

With hooks, the container component is sometimes replaced by a custom hook:

// Custom hook replaces the container
function useUsers() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(data => { setUsers(data); setLoading(false); });
  }, []);

  return { users, loading };
}

// Component uses the hook directly
function UserList() {
  const { users, loading } = useUsers();
  const [filter, setFilter] = useState('');

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

  // ... render
}

Both approaches are valid. The container/presentational split is still useful when the presentational component is reused with different data sources.

Props Drilling Solutions

Props drilling is when you pass props through multiple component layers just to reach a deeply nested component.

App (has theme)
  โ””โ”€โ”€ Layout
       โ””โ”€โ”€ Sidebar
            โ””โ”€โ”€ Navigation
                 โ””โ”€โ”€ NavItem (needs theme)

theme must pass through Layout, Sidebar, Navigation
even though they do not use it

Solution 1: Component Composition

Instead of passing data down, pass components down:

// BEFORE: props drilling
function App() {
  const [user, setUser] = useState(null);
  return <Layout user={user} />;
}
function Layout({ user }) {
  return <Sidebar user={user} />;
}
function Sidebar({ user }) {
  return <UserPanel user={user} />;
}

// AFTER: composition
function App() {
  const [user, setUser] = useState(null);

  return (
    <Layout sidebar={<Sidebar userPanel={<UserPanel user={user} />} />} />
  );
}
function Layout({ sidebar }) {
  return <div className="layout">{sidebar}</div>;
}
function Sidebar({ userPanel }) {
  return <div className="sidebar">{userPanel}</div>;
}

Now Layout and Sidebar do not need to know about user at all. They just render whatever they receive as props.

Solution 2: React Context

For truly global data (theme, auth, locale), context avoids prop drilling entirely:

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

const ThemeContext = createContext('light');

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  const toggle = () => setTheme(prev => prev === 'light' ? 'dark' : 'light');

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

function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme must be used within ThemeProvider');
  return context;
}

// Deep nested component accesses theme directly
function NavItem({ label, href }) {
  const { theme } = useTheme();
  return (
    <a href={href} className={`nav-item nav-item-${theme}`}>
      {label}
    </a>
  );
}

Solution 3: State Management Libraries

For complex shared state, Zustand, Jotai, or Redux provide global stores:

import { create } from 'zustand';

const useStore = create((set) => ({
  user: null,
  theme: 'light',
  setUser: (user) => set({ user }),
  toggleTheme: () => set(state => ({
    theme: state.theme === 'light' ? 'dark' : 'light',
  })),
}));

// Any component can access the store directly
function DeepNestedComponent() {
  const theme = useStore(state => state.theme);
  return <div className={theme}>Content</div>;
}

Choosing the Right Solution

SituationSolution
Data used by 1-2 intermediate componentsProps (drilling is fine)
Data used by distant leaf components, intermediates do not need itComposition
Truly global data (theme, auth, locale)Context
Complex shared state with frequent updatesState library (Zustand, Redux)

When to Use Each Pattern

+---------------------+------------------+-------------------+
| Pattern             | Ideal For        | Avoid When        |
+---------------------+------------------+-------------------+
| Compound Components | Flexible UI APIs | Simple components |
|                     | (tabs, accordion,| with 1-2 props    |
|                     |  menus, selects) |                   |
+---------------------+------------------+-------------------+
| Render Props        | Variable render  | Logic can live in |
|                     | output, library  | a custom hook     |
|                     | components       |                   |
+---------------------+------------------+-------------------+
| HOCs                | Cross-cutting    | New code with     |
|                     | concerns, class  | function comps    |
|                     | component compat |                   |
+---------------------+------------------+-------------------+
| Container /         | Reusable UI with | Component only    |
| Presentational      | swappable data   | used in one place |
+---------------------+------------------+-------------------+
| Custom Hooks        | Shared stateful  | Logic needs to    |
|                     | logic between    | control rendering |
|                     | function comps   |                   |
+---------------------+------------------+-------------------+
| Context             | Global data with | Frequently        |
|                     | infrequent       | changing data     |
|                     | updates          | (causes re-renders|
|                     |                  |  in all consumers)|
+---------------------+------------------+-------------------+

Combining Patterns

Real applications mix patterns. Here is a compound component that uses custom hooks internally and provides a render prop for maximum flexibility:

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

// Internal hook for sort logic
function useSortable(items, defaultSortKey) {
  const [sortKey, setSortKey] = useState(defaultSortKey);
  const [sortDir, setSortDir] = useState('asc');

  const sortedItems = [...items].sort((a, b) => {
    const valA = a[sortKey];
    const valB = b[sortKey];
    const cmp = valA > valB ? 1 : valA < valB ? -1 : 0;
    return sortDir === 'asc' ? cmp : -cmp;
  });

  const toggleSort = useCallback((key) => {
    if (key === sortKey) {
      setSortDir(prev => prev === 'asc' ? 'desc' : 'asc');
    } else {
      setSortKey(key);
      setSortDir('asc');
    }
  }, [sortKey]);

  return { sortedItems, sortKey, sortDir, toggleSort };
}

// Compound component context
const TableContext = createContext(null);

function SortableTable({ items, defaultSortKey, children }) {
  const sortable = useSortable(items, defaultSortKey);

  return (
    <TableContext.Provider value={sortable}>
      <table>{children}</table>
    </TableContext.Provider>
  );
}

function TableHead({ children }) {
  return <thead><tr>{children}</tr></thead>;
}

function SortableColumn({ sortKey, children }) {
  const { sortKey: currentKey, sortDir, toggleSort } = useContext(TableContext);
  const isActive = currentKey === sortKey;

  return (
    <th onClick={() => toggleSort(sortKey)} style={{ cursor: 'pointer' }}>
      {children}
      {isActive && (sortDir === 'asc' ? ' โ†‘' : ' โ†“')}
    </th>
  );
}

// Render prop for table body โ€” consumer controls row rendering
function TableBody({ renderRow }) {
  const { sortedItems } = useContext(TableContext);

  return (
    <tbody>
      {sortedItems.map((item, index) => renderRow(item, index))}
    </tbody>
  );
}

SortableTable.Head = TableHead;
SortableTable.Column = SortableColumn;
SortableTable.Body = TableBody;

Usage:

function UserTable({ users }) {
  return (
    <SortableTable items={users} defaultSortKey="name">
      <SortableTable.Head>
        <SortableTable.Column sortKey="name">Name</SortableTable.Column>
        <SortableTable.Column sortKey="email">Email</SortableTable.Column>
        <SortableTable.Column sortKey="role">Role</SortableTable.Column>
      </SortableTable.Head>

      <SortableTable.Body
        renderRow={(user) => (
          <tr key={user.id}>
            <td>{user.name}</td>
            <td>{user.email}</td>
            <td>{user.role}</td>
          </tr>
        )}
      />
    </SortableTable>
  );
}

This combines:

  • Compound components for the table structure (Head, Column, Body)
  • Custom hook for the sort logic (useSortable)
  • Render prop for flexible row rendering (renderRow)

Key Takeaways

  • Compound components create flexible, declarative APIs for complex UI elements
  • Render props let consumers control rendering while the component handles logic
  • HOCs inject behavior into components โ€” prefer hooks for new code, HOCs for legacy
  • Container/presentational separation aids testability and reusability
  • Props drilling is not always a problem โ€” use composition or context when it is
  • Custom hooks are the modern default for sharing stateful logic
  • Context is best for infrequent global data, not high-frequency state
  • Patterns combine โ€” compound components often use hooks internally and render props for flexibility
  • Choose the pattern that solves your specific problem with the least complexity

Found this helpful?

Support devsofus โ€” help us keep creating free dev guides.

Related Articles