React Component Patterns: Composition and Reuse

React's documentation explicitly states: 'React has a powerful composition model, and we recommend using composition instead of inheritance to reuse code between components.' This isn't just a...

Key Insights

  • Composition over inheritance is React’s fundamental design principle—build complex UIs by combining simple, focused components rather than extending base classes
  • Modern React favors custom hooks for logic reuse, but compound components and container/presentational patterns remain valuable for specific UI scenarios
  • Choose patterns based on what you’re sharing: hooks for stateful logic, compound components for related UI elements, and HOCs sparingly for cross-cutting concerns

Introduction to Component Composition

React’s documentation explicitly states: “React has a powerful composition model, and we recommend using composition instead of inheritance to reuse code between components.” This isn’t just a suggestion—it’s a fundamental principle that shapes how you should architect React applications.

Inheritance creates tight coupling and rigid hierarchies. Composition, by contrast, allows you to build complex functionality by combining simple, focused pieces. Think of it like building with LEGO blocks rather than carving a monolithic sculpture.

Here’s the anti-pattern you should avoid:

// ❌ Don't do this - inheritance anti-pattern
class BaseButton extends React.Component {
  handleClick = () => {
    this.props.onClick();
  }
  
  render() {
    return <button onClick={this.handleClick}>{this.props.children}</button>;
  }
}

class PrimaryButton extends BaseButton {
  render() {
    return <button className="primary" onClick={this.handleClick}>{this.props.children}</button>;
  }
}

Instead, use composition:

// ✅ Composition approach
const Button = ({ variant = 'default', onClick, children }) => {
  return (
    <button className={variant} onClick={onClick}>
      {children}
    </button>
  );
};

// Use it with different variants
<Button variant="primary" onClick={handleSubmit}>Submit</Button>
<Button variant="secondary" onClick={handleCancel}>Cancel</Button>

The composition approach is simpler, more flexible, and easier to test.

Container/Presentational Pattern

This pattern separates concerns by splitting components into two categories: containers that handle logic and state, and presentational components that focus purely on rendering UI.

Container components (sometimes called “smart” components) manage data fetching, state updates, and business logic. Presentational components (or “dumb” components) receive data via props and render UI.

// Container component - handles data and logic
const UserProfileContainer = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;
  
  return <UserCard user={user} />;
};

// Presentational component - pure rendering
const UserCard = ({ user }) => {
  return (
    <div className="user-card">
      <img src={user.avatar} alt={user.name} />
      <h2>{user.name}</h2>
      <p>{user.bio}</p>
      <div className="stats">
        <span>{user.followers} followers</span>
        <span>{user.following} following</span>
      </div>
    </div>
  );
};

This separation makes UserCard highly reusable and easy to test. You can render it in Storybook with mock data, use it in different contexts, and test it without worrying about API calls.

Compound Components Pattern

Compound components work together as a unit, sharing implicit state through React Context. This pattern creates intuitive APIs that feel natural to use.

const AccordionContext = createContext();

const Accordion = ({ children }) => {
  const [openIndex, setOpenIndex] = useState(null);

  const toggle = (index) => {
    setOpenIndex(openIndex === index ? null : index);
  };

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

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

const AccordionHeader = ({ index, children }) => {
  const { openIndex, toggle } = useContext(AccordionContext);
  const isOpen = openIndex === index;

  return (
    <button 
      className="accordion-header"
      onClick={() => toggle(index)}
    >
      {children}
      <span>{isOpen ? '−' : '+'}</span>
    </button>
  );
};

const AccordionPanel = ({ index, children }) => {
  const { openIndex } = useContext(AccordionContext);
  const isOpen = openIndex === index;

  return isOpen ? <div className="accordion-panel">{children}</div> : null;
};

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

// Usage - clean and intuitive
<Accordion>
  <Accordion.Item index={0}>
    <Accordion.Header index={0}>Section 1</Accordion.Header>
    <Accordion.Panel index={0}>Content for section 1</Accordion.Panel>
  </Accordion.Item>
  <Accordion.Item index={1}>
    <Accordion.Header index={1}>Section 2</Accordion.Header>
    <Accordion.Panel index={1}>Content for section 2</Accordion.Panel>
  </Accordion.Item>
</Accordion>

This pattern shines when components have a clear parent-child relationship and need to coordinate behavior.

Render Props Pattern

Render props let you share code by passing a function that returns React elements. The component calls this function instead of implementing its own render logic.

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

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

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

// Usage - flexible rendering based on mouse position
<MouseTracker 
  render={({ x, y }) => (
    <h1>Mouse position: {x}, {y}</h1>
  )}
/>

<MouseTracker 
  render={({ x, y }) => (
    <div style={{ 
      position: 'absolute', 
      left: x, 
      top: y 
    }}>
      📍
    </div>
  )}
/>

Render props provide maximum flexibility but can lead to “callback hell” if overused. Custom hooks have largely superseded this pattern for logic reuse.

Higher-Order Components (HOC)

HOCs are functions that take a component and return an enhanced version. They’re useful for cross-cutting concerns like authentication, logging, or loading states.

// HOC for authentication
const withAuth = (Component) => {
  return (props) => {
    const { user, loading } = useAuth();
    
    if (loading) return <LoadingSpinner />;
    if (!user) return <Navigate to="/login" />;
    
    return <Component {...props} user={user} />;
  };
};

// HOC for loading states
const withLoading = (Component) => {
  return ({ isLoading, ...props }) => {
    if (isLoading) return <LoadingSpinner />;
    return <Component {...props} />;
  };
};

// Usage
const Dashboard = ({ user }) => {
  return <div>Welcome, {user.name}!</div>;
};

export default withAuth(withLoading(Dashboard));

HOCs were popular before hooks, but they have drawbacks: props naming collisions, wrapper hell in DevTools, and static composition. Use them sparingly, primarily for cross-cutting concerns that truly need to wrap components.

Custom Hooks for Logic Reuse

Custom hooks are the modern, preferred way to share stateful logic. They’re composable, testable, and don’t add extra components to your tree.

// Toggle hook - simple boolean state management
const useToggle = (initialValue = false) => {
  const [value, setValue] = useState(initialValue);
  
  const toggle = useCallback(() => setValue(v => !v), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);
  
  return [value, { toggle, setTrue, setFalse }];
};

// Fetch hook - reusable data fetching
const useFetch = (url) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

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

  return { data, loading, error };
};

// Local storage hook - persist state
const useLocalStorage = (key, initialValue) => {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

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

  return [value, setValue];
};

// Usage - compose multiple hooks
const UserPreferences = () => {
  const [darkMode, setDarkMode] = useLocalStorage('darkMode', false);
  const [isOpen, { toggle }] = useToggle(false);
  const { data: user, loading } = useFetch('/api/user');

  if (loading) return <LoadingSpinner />;

  return (
    <div className={darkMode ? 'dark' : 'light'}>
      <button onClick={() => setDarkMode(!darkMode)}>
        Toggle theme
      </button>
      <button onClick={toggle}>
        {isOpen ? 'Close' : 'Open'} settings
      </button>
    </div>
  );
};

Custom hooks let you extract logic while keeping components clean and focused on rendering.

When to Use Each Pattern

Here’s how to solve the same problem—fetching data with loading states—using different patterns:

// Custom Hook approach (recommended for most cases)
const UserProfile = ({ userId }) => {
  const { data, loading, error } = useFetch(`/api/users/${userId}`);
  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;
  return <UserCard user={data} />;
};

// Container/Presentational (when you need reusable presentational components)
const UserProfileContainer = ({ userId }) => {
  const { data, loading, error } = useFetch(`/api/users/${userId}`);
  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;
  return <UserCard user={data} />; // UserCard is reusable elsewhere
};

// HOC approach (for cross-cutting concerns)
const withUserData = (Component) => {
  return ({ userId, ...props }) => {
    const { data, loading, error } = useFetch(`/api/users/${userId}`);
    if (loading) return <LoadingSpinner />;
    if (error) return <ErrorMessage error={error} />;
    return <Component user={data} {...props} />;
  };
};

Use custom hooks when: You’re sharing stateful logic, side effects, or need to compose multiple behaviors. This is your default choice.

Use container/presentational when: You have complex presentational components that will be reused in multiple contexts (Storybook, different pages, etc.).

Use compound components when: You’re building component families that need to share state (tabs, accordions, select menus, form groups).

Use HOCs when: You need to wrap multiple components with the same cross-cutting concern and hooks aren’t sufficient (rare in modern React).

The React ecosystem has evolved. Custom hooks have become the primary tool for logic reuse, but understanding all these patterns helps you choose the right tool for each situation. Start with hooks, reach for other patterns when they provide clear benefits.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.