Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

React Component Patterns: Composition and Reusability

March 5, 2025 · 9 min read

React, Design Patterns, JavaScript, Architecture, Interview Prep

Introduction

Component patterns are what separate junior from senior React developers. While anyone can build components, designing reusable, flexible component APIs requires understanding proven patterns. This article covers the patterns that appear in every mature React codebase.


Composition Over Inheritance

React's component model is built on composition. You combine simple components to build complex UIs, rather than inheriting from base classes.

// Specialization through composition
function Dialog({ title, children }) {
  return (
    <div className="dialog">
      <h2>{title}</h2>
      <div className="dialog-content">{children}</div>
    </div>
  );
}

function ConfirmDialog({ title, message, onConfirm, onCancel }) {
  return (
    <Dialog title={title}>
      <p>{message}</p>
      <div className="dialog-actions">
        <button onClick={onCancel}>Cancel</button>
        <button onClick={onConfirm}>Confirm</button>
      </div>
    </Dialog>
  );
}

function DeleteConfirmDialog({ itemName, onConfirm, onCancel }) {
  return (
    <ConfirmDialog
      title="Confirm Delete"
      message={`Are you sure you want to delete "${itemName}"?`}
      onConfirm={onConfirm}
      onCancel={onCancel}
    />
  );
}

Why Composition Wins

Inheritance creates tight coupling and rigid hierarchies. Composition lets you mix and match behaviors freely. Need a modal that's also a form? Compose them. Need different button styles? Compose with different className props.


Compound Components

Compound components are a group of components that work together to form a complete UI. Think of <select> and <option> in HTML - they're meaningless alone but powerful together.

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

const TabsContext = createContext(null);

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

  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 ? 'active' : ''}`}
      onClick={() => setActiveTab(value)}
    >
      {children}
    </button>
  );
}

function TabPanels({ children }) {
  return <div className="tab-panels">{children}</div>;
}

function TabPanel({ value, children }) {
  const { activeTab } = useContext(TabsContext);

  if (activeTab !== value) return null;

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

// Attach sub-components
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panels = TabPanels;
Tabs.Panel = TabPanel;

// Usage - clean, declarative API
function Settings() {
  return (
    <Tabs defaultTab="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.Panels>
        <Tabs.Panel value="general">
          <GeneralSettings />
        </Tabs.Panel>
        <Tabs.Panel value="security">
          <SecuritySettings />
        </Tabs.Panel>
        <Tabs.Panel value="notifications">
          <NotificationSettings />
        </Tabs.Panel>
      </Tabs.Panels>
    </Tabs>
  );
}

Benefits of Compound Components

  1. Flexible layout - Users control the structure
  2. Implicit state sharing - Context handles communication
  3. Clean API - No prop drilling through intermediate components
  4. Extensible - Easy to add new sub-components

Compound Components in Practice

Compound components transformed how I built complex UIs in Glucoplate. Instead of prop drilling through multiple levels, related components share state implicitly. The code became dramatically cleaner and easier to test.


Render Props

Render props let a component delegate rendering to its parent. The component provides data/behavior; the parent decides how to render it.

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

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

    window.addEventListener('mousemove', handleMouseMove);
    return () => window.removeEventListener('mousemove', handleMouseMove);
  }, []);

  return render(position);
}

// Usage - you decide how to render
function App() {
  return (
    <MouseTracker
      render={({ x, y }) => (
        <div>
          <p>Mouse position: {x}, {y}</p>
          <div
            className="cursor-follower"
            style={{ left: x, top: y }}
          />
        </div>
      )}
    />
  );
}

Children as Render Prop

The same pattern using children:

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(data => {
        setData(data);
        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>
  );
}

Render Props vs Hooks

Render props were more common before hooks. Today, most render prop use cases are better served by custom hooks. However, render props still shine when you need to share behavior AND control the rendered output (like in animation libraries).


Higher-Order Components (HOCs)

HOCs are functions that take a component and return an enhanced component. They wrap functionality around existing components.

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

// Usage
const UserListWithLoading = withLoading(UserList);

<UserListWithLoading isLoading={loading} users={users} />

Authentication HOC

function withAuth(WrappedComponent) {
  return function WithAuthComponent(props) {
    const { user, isAuthenticated } = useAuth();

    if (!isAuthenticated) {
      return <Navigate to="/login" />;
    }

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

// Protect routes
const ProtectedDashboard = withAuth(Dashboard);

Composing Multiple HOCs

// Multiple HOCs can be composed
const EnhancedComponent = withAuth(
  withLoading(
    withErrorBoundary(BaseComponent)
  )
);

// Or use compose utility
import { compose } from 'lodash';

const enhance = compose(
  withAuth,
  withLoading,
  withErrorBoundary
);

const EnhancedComponent = enhance(BaseComponent);

HOC Gotchas

HOCs have drawbacks: wrapper hell in DevTools, prop name collisions, and difficulty with refs. Custom hooks often provide cleaner solutions. Use HOCs when you need to wrap JSX, not just share logic.


Container/Presentational Pattern

Separate data-fetching/logic from presentation. This pattern improves testability and reusability.

// Container - handles data and logic
function UserListContainer() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [sortOrder, setSortOrder] = useState('name');

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

  const sortedUsers = useMemo(() => {
    return [...users].sort((a, b) =>
      a[sortOrder].localeCompare(b[sortOrder])
    );
  }, [users, sortOrder]);

  const handleDelete = async (userId) => {
    await deleteUser(userId);
    setUsers(users.filter(u => u.id !== userId));
  };

  return (
    <UserList
      users={sortedUsers}
      loading={loading}
      sortOrder={sortOrder}
      onSortChange={setSortOrder}
      onDelete={handleDelete}
    />
  );
}

// Presentational - pure rendering
function UserList({ users, loading, sortOrder, onSortChange, onDelete }) {
  if (loading) return <Spinner />;

  return (
    <div className="user-list">
      <SortDropdown value={sortOrder} onChange={onSortChange} />

      {users.map(user => (
        <UserCard
          key={user.id}
          user={user}
          onDelete={() => onDelete(user.id)}
        />
      ))}
    </div>
  );
}

Benefits

  1. Testability - Presentational components are pure; test with props
  2. Reusability - Presentational components work with any data source
  3. Separation of concerns - Logic and UI evolve independently

Controlled vs Uncontrolled Components

Controlled

Parent owns the state; component is a "dumb" input:

function ControlledInput({ value, onChange }) {
  return (
    <input
      value={value}
      onChange={e => onChange(e.target.value)}
    />
  );
}

// Parent controls everything
function Form() {
  const [name, setName] = useState('');

  return (
    <ControlledInput
      value={name}
      onChange={setName}
    />
  );
}

Uncontrolled

Component manages its own state; parent accesses via ref:

function UncontrolledInput({ defaultValue }) {
  const inputRef = useRef(null);

  return (
    <input
      ref={inputRef}
      defaultValue={defaultValue}
    />
  );
}

// Parent reads value when needed
function Form() {
  const inputRef = useRef();

  const handleSubmit = () => {
    console.log(inputRef.current.value);
  };

  return <UncontrolledInput ref={inputRef} />;
}

Hybrid: Controlled with Internal State

function SearchInput({ value, onChange, onSearch }) {
  // Internal state for immediate feedback
  const [localValue, setLocalValue] = useState(value);

  // Sync with controlled value
  useEffect(() => {
    setLocalValue(value);
  }, [value]);

  const handleChange = (e) => {
    setLocalValue(e.target.value);
    // Debounce before notifying parent
    debouncedOnChange(e.target.value);
  };

  return (
    <input
      value={localValue}
      onChange={handleChange}
      onKeyDown={e => e.key === 'Enter' && onSearch(localValue)}
    />
  );
}

Questions Worth Considering

Q1: What are compound components and when would you use them?

Answer: Compound components are related components that share implicit state through context. Use them when building complex UI widgets like tabs, accordions, or dropdown menus where:

  • Components are meaningless alone but powerful together
  • You want flexible layout control
  • State should be shared without prop drilling

Q2: Render props vs custom hooks - when to use each?

Answer:

  • Custom hooks for sharing stateful logic (data, loading states, handlers)
  • Render props when you need to control the rendered output along with shared logic

Hooks are generally preferred for logic reuse. Render props are still valuable in animation libraries or when the rendering decision depends on the shared state in complex ways.

Q3: What problems do HOCs solve and what are their drawbacks?

Answer: HOCs solve cross-cutting concerns: auth, logging, error boundaries, data fetching wrappers.

Drawbacks:

  • Wrapper hell in React DevTools
  • Prop name collisions
  • Static composition (can't use hooks conditionally)
  • Refs don't pass through automatically

For most cases, custom hooks provide cleaner solutions.

Q4: Explain the Container/Presentational pattern.

Answer: Separate components into two types:

  • Containers handle data fetching, state management, and business logic
  • Presentational components receive props and render UI

Benefits: testability (presentational are pure), reusability (same presentation with different data sources), separation of concerns.


Summary

  • Composition over inheritance - combine components, don't extend them
  • Compound components share implicit state via context for flexible APIs
  • Render props delegate rendering decisions to consumers
  • HOCs wrap components to add behavior (but consider hooks first)
  • Container/Presentational separates logic from UI
  • Controlled vs Uncontrolled determines who owns the state

Quick Reference: When to Use Each Pattern

PatternUse WhenExample
CompositionBuilding specialized versions of componentsConfirmDialog wrapping Dialog
Compound ComponentsRelated components share state, user controls layoutTabs, Accordion, Dropdown Menu
Render PropsShare behavior AND let consumer control renderingMouse tracking, animation libraries
HOCsAdd wrapper behavior to existing componentsAuth guards, error boundaries
Container/PresentationalNeed testable, reusable presentation layerData tables, user lists
ControlledParent needs to validate/transform inputForms with validation
UncontrolledSimple inputs, parent only reads on submitQuick forms, file inputs

Modern React Preference

In 2025, the pattern hierarchy is: Custom Hooks > Composition > Render Props > HOCs. Start with hooks for logic sharing. Use compound components for complex UI widgets. Reserve HOCs for true wrapper scenarios like route protection.

These patterns are essential for building maintainable React applications.


Part 3 of the React Developer Reference series.

← Previous

React Router: Complete Navigation Guide

Next →

React State and Hooks: The Complete Guide