Back to BlogReact

Mastering Frontend Composition Patterns in React

Planobit Team
November 7, 2025
12 min read
Mastering Frontend Composition Patterns in React

In the ever-evolving landscape of frontend development, composition patterns have emerged as a fundamental concept for building scalable, maintainable, and reusable React applications. Understanding these patterns is crucial for any developer looking to write clean, efficient code.

This comprehensive guide explores the most important composition patterns in React, providing practical examples and best practices to help you master these essential techniques.

1. Component Composition

Component composition is the foundation of React's design philosophy. Instead of using inheritance, React encourages building complex UIs by composing smaller, focused components together. This approach promotes code reusability, maintainability, and separation of concerns.

Think of composition like building with LEGO blocks - you create small, single-purpose components that can be combined in countless ways to build complex interfaces. Each component does one thing well and can be reused across your application. This modular approach makes your codebase easier to understand, test, and maintain.

The Children Prop Pattern

The most basic and powerful form of composition uses the children prop. This special prop allows you to pass content between component opening and closing tags, just like HTML elements. The container component doesn't need to know what it's wrapping - it just provides the structure and styling.

This pattern is incredibly useful for creating reusable layout components like cards, modals, panels, and containers. The outer component handles the presentation logic while the inner content remains completely flexible. Here's a simple example:

// Container component
function Card({ children }) {
  return (
    <div className="card">
      {children}
    </div>
  );
}

// Usage
function App() {
  return (
    <Card>
      <h2>Welcome</h2>
      <p>This is composed content</p>
    </Card>
  );
}

This pattern provides maximum flexibility, allowing parent components to control what gets rendered inside child components without the child needing to know the specifics.

Multiple Composition Points

While the children prop is great for single content areas, real-world components often need multiple customization points. A dialog might need separate areas for the title, content, and action buttons. A card might need a header, body, and footer section. In these cases, using multiple props (each accepting JSX) gives you fine-grained control.

This approach is sometimes called "slot-based composition" because you're defining specific slots where content can be injected. Each prop acts as a named placeholder that can accept any React element. Here's how you might implement a dialog with multiple composition points:

function Dialog({ title, content, actions }) {
  return (
    <div className="dialog">
      <div className="dialog-header">{title}</div>
      <div className="dialog-content">{content}</div>
      <div className="dialog-actions">{actions}</div>
    </div>
  );
}

// Usage
<Dialog
  title={<h2>Confirm Action</h2>}
  content={<p>Are you sure?</p>}
  actions={
    <>
      <Button>Cancel</Button>
      <Button>Confirm</Button>
    </>
  }
/>

2. Compound Components Pattern

Compound components are a powerful pattern where multiple components work together to form a cohesive UI element. Unlike basic composition, compound components share implicit state and communicate with each other behind the scenes. This creates an elegant, declarative API that's both flexible and easy to understand.

Think of HTML's <select> and <option> elements - they work together as a unit, with the select element managing which option is selected. Compound components follow the same principle in React, using Context API to share state between related components.

This pattern is particularly useful for building component libraries where you want to give users flexibility in layout while maintaining consistent behavior. Popular libraries like Reach UI and Radix UI rely heavily on compound components. Let's build a Tabs component to see how this works:

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

// Create context for shared state
const TabsContext = createContext();

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">{children}</div>;
}

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

  return (
    <button
      className={activeTab === value ? 'active' : ''}
      onClick={() => setActiveTab(value)}
    >
      {children}
    </button>
  );
}

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

  return activeTab === value ? (
    <div className="tab-panel">{children}</div>
  ) : null;
}

// Export as compound component
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;

// Usage
<Tabs defaultTab="profile">
  <Tabs.List>
    <Tabs.Tab value="profile">Profile</Tabs.Tab>
    <Tabs.Tab value="settings">Settings</Tabs.Tab>
  </Tabs.List>

  <Tabs.Panel value="profile">
    <ProfileContent />
  </Tabs.Panel>
  <Tabs.Panel value="settings">
    <SettingsContent />
  </Tabs.Panel>
</Tabs>

This pattern is powerful because it provides a clear, declarative API while handling complex state management internally. Libraries like Radix UI and Reach UI use this pattern extensively.

3. Render Props Pattern

Render props is a technique for sharing stateful logic between components by passing a function as a prop. The component with the logic calls this function and passes data to it, while the function returns what should be rendered. This inverts control - the parent decides what to render, while the child decides what data to provide.

Before React Hooks, render props were one of the primary ways to share stateful logic without creating a HOC (Higher-Order Component). The pattern gives consumers complete control over the rendered output while the component handles all the complex logic internally. It's like saying "I'll give you the data, you decide how to display it."

Let's look at a practical example - a component that tracks mouse position. The component handles the logic of tracking the cursor, but different consumers might want to display this information in completely different ways:

function MouseTracker({ render }) {
  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 render(position);
}

// Usage 1: Display coordinates
<MouseTracker
  render={({ x, y }) => (
    <div>Mouse position: {x}, {y}</div>
  )}
/>

// Usage 2: Follow cursor with element
<MouseTracker
  render={({ x, y }) => (
    <div style={{ position: 'fixed', left: x, top: y }}>
      🎯
    </div>
  )}
/>

// Modern alternative: children as function
function MouseTracker({ children }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  // ... same logic
  return children(position);
}

<MouseTracker>
  {({ x, y }) => <div>Position: {x}, {y}</div>}
</MouseTracker>

Render props are excellent for sharing stateful logic while giving consumers complete control over the UI. However, custom hooks have largely replaced this pattern in modern React.

4. Higher-Order Components (HOCs)

A Higher-Order Component (HOC) is an advanced pattern borrowed from functional programming. It's a function that takes a component as input and returns a new, enhanced component with additional functionality. Think of it as a component decorator that wraps your original component with extra capabilities.

Before hooks became the standard, HOCs were the primary method for reusing component logic. Popular libraries like Redux's connect() and React Router's withRouter() are famous examples of HOCs. While custom hooks have largely replaced HOCs for most use cases, understanding this pattern is still valuable for maintaining legacy code and certain specific scenarios.

HOCs are particularly useful for cross-cutting concerns like authentication, logging, error boundaries, and loading states - functionality that needs to be applied to many different components. Here are some practical examples:

// HOC for adding loading state
function withLoading(Component) {
  return function WithLoadingComponent({ isLoading, ...props }) {
    if (isLoading) {
      return <div className="spinner">Loading...</div>;
    }
    return <Component {...props} />;
  };
}

// HOC for authentication
function withAuth(Component) {
  return function WithAuthComponent(props) {
    const { user, isAuthenticated } = useAuth();

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

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

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

const UserProfileWithAuth = withAuth(UserProfile);
const UserProfileWithLoadingAndAuth = withLoading(withAuth(UserProfile));

// In your app
<UserProfileWithLoadingAndAuth isLoading={loading} />

Note: Custom hooks are now preferred over HOCs for most use cases as they're more composable and easier to understand.

5. Custom Hooks Pattern

Custom hooks represent a paradigm shift in how we think about code reuse in React. Introduced in React 16.8, hooks allow you to extract stateful logic into reusable functions that can be shared across components. They're composable, testable, and more intuitive than HOCs or render props.

A custom hook is simply a JavaScript function whose name starts with "use" and that can call other hooks. Unlike HOCs which wrap your components, or render props which change your JSX structure, custom hooks extract logic while keeping your component structure clean. They're perfect for sharing stateful behavior like data fetching, form handling, animations, or subscriptions.

The beauty of custom hooks is their simplicity - they're just functions that leverage React's hook system. You can compose multiple hooks together, test them in isolation, and share them across your application or even publish them as npm packages. Let's look at some practical examples:

// Custom hook for API data fetching
function useApiData(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    async function fetchData() {
      try {
        setLoading(true);
        const response = await fetch(url);
        const json = await response.json();

        if (!cancelled) {
          setData(json);
          setError(null);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    fetchData();

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

// Custom hook for form handling
function useForm(initialValues) {
  const [values, setValues] = useState(initialValues);

  const handleChange = (event) => {
    setValues({
      ...values,
      [event.target.name]: event.target.value
    });
  };

  const reset = () => setValues(initialValues);

  return { values, handleChange, reset };
}

// Usage
function UserProfile() {
  const { data, loading, error } = useApiData('/api/user');
  const { values, handleChange } = useForm({ name: '', email: '' });

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <div>
      <h2>{data.name}</h2>
      <input name="name" value={values.name} onChange={handleChange} />
    </div>
  );
}

6. Container/Presenter Pattern

The Container/Presenter pattern (also called Smart/Dumb or Stateful/Stateless components) is an architectural pattern that separates business logic from presentation. Container components handle the "how things work" while presenter components handle the "how things look." This separation of concerns makes your code more maintainable and your UI components more reusable.

Presenter components are pure - they receive data via props and render UI. They don't know where the data comes from or how to fetch it. This makes them incredibly easy to test (just pass props and verify output) and reusable (same presenter, different containers). Container components, on the other hand, handle all the messy business logic: API calls, state management, side effects, and event handlers.

While hooks have blurred the lines between containers and presenters (you can now have state in any component), the principle remains valuable: separating logic from presentation leads to cleaner, more testable code. Here's how this pattern works in practice:

// Presenter: Pure UI component
function UserList({ users, onUserClick }) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id} onClick={() => onUserClick(user)}>
          {user.name}
        </li>
      ))}
    </ul>
  );
}

// Container: Handles logic and state
function UserListContainer() {
  const { data: users, loading } = useApiData('/api/users');
  const navigate = useNavigate();

  const handleUserClick = (user) => {
    // Business logic
    trackEvent('user_clicked', { userId: user.id });
    navigate(`/users/${user.id}`);
  };

  if (loading) return <Spinner />;

  return <UserList users={users} onUserClick={handleUserClick} />;
}

// Benefits:
// - Easy to test presenters (just pass props)
// - Reusable presenters with different containers
// - Clear separation of concerns

7. Provider Pattern

The Provider pattern solves one of React's most frustrating problems: prop drilling. When you need to pass data through many layers of components, manually passing props becomes tedious and makes your code brittle. The Provider pattern uses React's Context API to make data available anywhere in the component tree without explicitly passing it through every level.

Think of Context as a broadcast system - the Provider broadcasts data, and any component can tune in to receive it using a hook (like useContext). This is perfect for global or semi-global state like themes, user authentication, language preferences, or feature flags. It's not meant to replace all prop passing - only use Context when data truly needs to be accessible by many components at different nesting levels.

The pattern typically involves creating a Context, a Provider component that wraps part of your app, and a custom hook for consuming the context. This encapsulation makes the API clean and prevents common mistakes. Here's a complete example with a theme provider:

// Create theme context
const ThemeContext = createContext();

// Provider component
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };

  const value = {
    theme,
    toggleTheme,
    colors: theme === 'light' ? lightColors : darkColors
  };

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

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

// Usage in App
function App() {
  return (
    <ThemeProvider>
      <Header />
      <Main />
      <Footer />
    </ThemeProvider>
  );
}

// Usage in any child component
function Header() {
  const { theme, toggleTheme } = useTheme();

  return (
    <header className={theme}>
      <button onClick={toggleTheme}>
        Toggle Theme
      </button>
    </header>
  );
}

Best Practices

Key Principles

  • 1.Favor Composition Over Inheritance: Always prefer composing components over creating complex inheritance hierarchies.
  • 2.Keep Components Focused: Each component should have a single responsibility and do it well.
  • 3.Use Custom Hooks for Logic: Extract reusable stateful logic into custom hooks rather than HOCs or render props.
  • 4.Compound Components for Related UI: Use compound components when you have a set of components that work together.
  • 5.Context for Global State: Use Context API for truly global state, but avoid overusing it for local state.
  • 6.Props for Flexibility: Make components flexible by accepting props for customization rather than hardcoding values.

When to Use Each Pattern

Component Composition

Use when: Building reusable layout components, creating flexible container components, or when you need maximum flexibility.

Compound Components

Use when: Creating component libraries, building complex UI widgets (tabs, accordions, dropdowns), or when components need to share implicit state.

Custom Hooks

Use when: Sharing stateful logic, handling side effects, or abstracting complex state management. This should be your go-to pattern for most logic sharing needs.

Provider Pattern

Use when: Managing global state (theme, auth, language), avoiding prop drilling for deeply nested components, or creating feature-specific contexts.

Conclusion

Mastering composition patterns is essential for building maintainable React applications. While the ecosystem has evolved (with custom hooks becoming the preferred pattern for logic sharing), understanding all these patterns helps you make informed architectural decisions.

Start with simple component composition, use custom hooks for stateful logic, and reach for compound components when building complex UI widgets. Remember: the best pattern is the one that makes your code more readable and maintainable.

Happy composing! 🚀

Need Help with Your React Project?

Our team of expert developers can help you build scalable, maintainable React applications using best practices and modern patterns.

Get in Touch