React Design Patterns

By Tomasz Gajda

React has become a go-to tool for building dynamic and interactive web applications. Over the years, the way developers write React applications has changed a lot, driven by new ideas and techniques. These changes, known as design patterns, help us write better, cleaner, and more efficient code.

In this blog post, we'll take a look at the journey of React design patterns. We'll start with the simple patterns used in the early days of React, which, though partially outdated, remain relevant for the context and experience of a React developer. Then, we'll move on to the more advanced patterns that developers started using as React grew in popularity and complexity.

Understanding these patterns and their evolution will help you improve your React skills, whether you're a beginner or an experienced developer. By learning from the past and keeping up with the latest best practices, you can build React applications that are not only functional but also easy to maintain and scale.

1. The Container/Presentational pattern

The Container/Presentational pattern is a design approach that helps to separate the logic of a component from its view. By dividing components into two distinct categories, you can manage your code more effectively and keep concerns separated.

1.1. How does the Container/Presentational Pattern work?

The pattern consists of two types of components that work together to separate concerns within a React application: Presentational Components: These components are concerned with how things look. They receive data and callbacks exclusively via props. They are often stateless functional components, but they can be stateful if needed for UI purposes (e.g., managing form input states). Container Components: These components are concerned with how things work. They handle the data fetching, state management, and business logic. They pass the necessary data down to the presentational components as props.

1.2. Example Scenario

Suppose we want to show listings on a landing page. Using the Container/Presentational pattern, we can have: A Container Component that fetches the data for the recent listings. A Presentational Component that receives this data via props and renders it to the user.

// Presentational Component
const Listings = ({ listings }) => (
  <div>
    {listings.map((listing) => (
      <div key={listing.id}>{listing.name}</div>
    ))}
  </div>
);

// Container Component
class ListingsContainer extends React.Component {
  state = {
    listings: [],
  };

  componentDidMount() {
    fetch('/api/listings')
      .then((response) => response.json())
      .then((data) => this.setState({ listings: data }));
  }

  render() {
    return <Listings listings={this.state.listings} />;
  }
}    

1.3. Tradeoffs

+ Separation of Concerns

  • Presentational components focus purely on the UI, making them simpler and more readable.
  • Container components handle data and state, making it easier to manage the business logic separately.

+ Reusability

  • Presentational components can be reused across different parts of the application with different data inputs.

+ Flexibility

  • Designers can modify the appearance of presentational components without needing to understand the underlying logic.
  • Consistent changes can be applied across the application if the same presentational component is reused.

+ Testing

  • Testing presentational components is straightforward as they are typically pure functions.
  • You can predict the output based on the props provided, without mocking complex data stores.

- Not Necessary with Hooks

  • With the introduction of React Hooks, the separation of concerns can be achieved without explicitly splitting components into container and presentational.
  • Hooks like useState and useEffect allow functional components to manage state and side effects directly.

For example, using a hook to fetch data:

import useSWR from 'swr';

const Listings = () => {
  const { data: listings, error } = useSWR('/api/listings');

  if (error) return <div>Failed to load</div>;
  if (!listings) return <div>Loading...</div>;

  return (
    <div>
      {listings.map((listing) => (
        <div key={listing.id}>{listing.name}</div>
      ))}
    </div>
  );
};

This approach keeps the component small, maintainable, and straightforward without the need to separate into container and presentational components.

2. The Higher-Order Components pattern

Higher-Order Components (HOCs) are a powerful pattern in React for reusing component logic. An HOC is a function that takes a component and returns a new component with additional props or behavior. This makes it easy to share logic across multiple components without duplicating code.

2.1. How does the Higher-Order Component pattern work?

The Higher-Order Component (HOC) pattern involves creating a function that takes a component as an argument and returns a new component with additional props or behavior. This pattern allows for the reuse of component logic across multiple components without modifying their individual implementations.

  • Define an HOC: An HOC is a function that takes a component and returns a new component with added props or logic.
const withEnhancements = (WrappedComponent) => {
return (props) => {
 // Add or modify props
 const newProps = {
   // ... additional logic or data
 };
 return <WrappedComponent {...props} {...newProps} />;
};
};
  • Wrap Components: Use the HOC to wrap the components you want to enhance. The wrapped component will receive the additional props or behavior defined in the HOC.
const EnhancedComponent = withEnhancements(OriginalComponent);
  • Use the Enhanced Component: The enhanced component can be used in place of the original component, now with the added functionality provided by the HOC.
const App = () => (
  <div>
    <EnhancedComponent someProp="value" />
  </div>
);

The HOC pattern promotes code reuse and separation of concerns by encapsulating the shared logic in a single place, making the individual components simpler and more focused on their specific tasks. However, it can introduce complexity and potential prop naming collisions, so careful management is necessary.

2.2. Example Scenario

Suppose we want to change the styles of a text component by making the font larger and the font weight bolder. We can create two Higher-Order Components to achieve this:

  • withLargeFontSize: This HOC adds a font-size: "90px" style to the component.
  • withBoldFontWeight: This HOC adds a font-weight: "bold" style to the component.
import React from 'react';

// HOC to add large font size
const withLargeFontSize = (WrappedComponent) => (props) => {
 return (
   <WrappedComponent {...props} style={{ ...props.style, fontSize: '90px' }} />
 );
};

// HOC to add bold font weight
const withBoldFontWeight = (WrappedComponent) => (props) => {
 return (
   <WrappedComponent {...props} style={{ ...props.style, fontWeight: 'bold' }} />
 );
};

// Base component
const TextComponent = (props) => <div style={props.style}>{props.children}</div>;

// Enhanced components
const LargeText = withLargeFontSize(TextComponent);
const BoldText = withBoldFontWeight(TextComponent);

// Using the enhanced components
const App = () => (
 <div>
   <LargeText style={{ color: 'blue' }}>This is large text</LargeText>
   <BoldText style={{ color: 'red' }}>This is bold text</BoldText>
 </div>
);

export default App;

2.3 Tradeoffs

+ Separation of Concerns

  • HOCs allow you to keep reusable logic in one place. This reduces the risk of bugs from duplicating code across the application.
  • By encapsulating logic in HOCs, you can easily update or fix issues in one place rather than searching through multiple components.

+ Reusability

  • HOCs make it easy to apply the same behaviour to different components without repeating code.
  • They promote code reuse and DRY (Don't Repeat Yourself) principles.

+ Composability

  • HOCs can be composed together to add multiple behaviours to a component, allowing for flexible and powerful compositions.

- Naming Collisions

  • HOCs can accidentally override props of the wrapped component, leading to unexpected behaviour. To avoid this, ensure that HOCs handle prop naming carefully.
  • You can rename conflicting props or merge props appropriately to avoid collisions.

- Increased Complexity

  • While HOCs provide powerful abstractions, they can add complexity to your codebase. Understanding the flow of props and behaviour through multiple HOCs can be challenging.
  • Overusing HOCs can lead to "wrapper hell," where components are wrapped in multiple layers of HOCs, making debugging and maintenance harder.

3. Render Props pattern

The Render Props pattern in React is a powerful technique for sharing logic across multiple components. It involves passing a function as a prop to a component, and this function is used to render part of the UI. This pattern allows for great flexibility and reusability in your components.

3.1. How does the Render Prop pattern work?

The Render Props pattern involves passing a function as a prop to a component. This function, known as a "render prop," is used to determine what the component should render, allowing for reusable logic across different components.

  • Define a Component with a Render Prop: Create a component that accepts a function prop, which it calls to render its output.
class DataProvider extends React.Component {
 state = { data: null, loading: true };

 componentDidMount() {
   fetchData().then((data) => this.setState({ data, loading: false }));
 }

 render() {
   return this.props.render(this.state);
 }
}
  • Use the Component with a Render Prop: Pass a function to the component that receives its state and returns the desired UI.
const App = () => (
 <DataProvider render={({ data, loading }) => (
   loading ? <div>Loading...</div> : <div>Data: {data}</div>
 )} />
);

3.2. Example Scenario

import React, { Component } from 'react';
// Data fetching component with render prop
class DataFetcher extends Component {
  state = {
    data: null,
    loading: true,
    error: null,
  };

  componentDidMount() {
    fetch(this.props.url)
      .then((response) => response.json())
      .then((data) => this.setState({ data, loading: false }))
      .catch((error) => this.setState({ error, loading: false }));
  }

  render() {
    return this.props.render(this.state);
  }
}

// Component using DataFetcher
const App = () => (
  <DataFetcher
    url="/api/data"
    render={({ data, loading, error }) => {
      if (loading) return <div>Loading...</div>;
      if (error) return <div>Error: {error.message}</div>;
      return <div>Data: {JSON.stringify(data)}</div>;
    }}
  />
);

export default App;

3.3. Tradeoffs

+ Reusability

  • Components that use render props are highly reusable. Since the render prop can be different each time, you can adapt the logic to multiple use cases without changing the underlying component.

+ Separation of Concerns

  • Render props help separate logic from rendering. The stateful component (like DataFetcher) handles data fetching, while stateless components handle the rendering, making the code more modular and easier to maintain.

+ Solution to HOC Problems

  • Render props provide a more explicit and clear way to pass data and callbacks. Unlike HOCs, which can implicitly pass props and lead to naming collisions, render props list all the props explicitly in the arguments, making it easier to track and debug.

- Unnecessary with Hooks

  • The introduction of React Hooks has changed how we handle reusability and data sharing. Hooks like useState, useEffect, and custom hooks can often replace the need for render props, leading to simpler and more readable code.

4. The Hooks Pattern in React

React Hooks are special functions that enable functional components to use state and other React features. Introduced in React 16.8, hooks provide a way to use stateful logic and lifecycle methods in functional components, simplifying component development and promoting code reuse.

4.1. What are React Hooks?

Hooks are functions that allow you to "hook into" React features from functional components. Here are some of the primary use cases:

  • Add state to a functional component: useState lets you add state to functional components.
  • Reuse stateful logic among multiple components: Custom hooks enable sharing stateful logic across different components.
  • Manage a component's lifecycle: useEffect allows you to run side effects at specific points in a component's lifecycle. Besides built-in hooks like useState, useEffect, and useReducer, you can create custom hooks to encapsulate and reuse stateful logic throughout your application.

4.2. Example Scenario

Let's say we want to fetch user data and display it. We can use the useState and useEffect hooks to handle the state and lifecycle of our data fetching logic in a functional component.

import React, { useState, useEffect } from 'react';

const useFetch = (url) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(url)
      .then((response) => response.json())
      .then((data) => {
        setData(data);
        setLoading(false);
      });
  }, [url]);

  return { data, loading };
};

const UserList = () => {
  const { data, loading } = useFetch('/api/users');

  return (
    <div>
      {loading ? (
        <div>Loading...</div>
      ) : (
        <ul>
          {data.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
};

const App = () => (
  <div>
    <h1>User List</h1>
    <UserList />
  </div>
);

export default App;

4.3. Tradeoffs

+ Simplifies Components

  • Hooks make it easy to add state and other React features to functional components, avoiding the complexity often associated with class components.
  • Functional components with hooks are generally easier to read, write, and maintain.

+ Reusing Stateful Logic

  • Hooks allow you to extract and reuse stateful logic across multiple components. This reduces code duplication and makes it easier to manage and test your application.
  • Custom hooks can be created to encapsulate complex logic and share it between components, enhancing code reusability.

+ Sharing Non-Visual Logic

  • Hooks enable you to share non-visual logic without using patterns like HOCs or Render Props. This makes the code cleaner and more understandable.
  • For instance, hooks can manage data fetching, subscriptions, and other side effects in a concise and reusable manner.

+ Good Alternative to Older React Design Patterns

  • Hooks provide a modern alternative to older React design patterns such as the Presentational/Container pattern and HOCs, which were primarily used with class components.
  • With hooks, functional components can handle state and side effects, making the codebase more consistent and reducing the need for class components.

- Rules of Hooks

  • Hooks must be used according to specific rules, such as only calling hooks at the top level of a component or custom hook and only from React function components or custom hooks.
  • Without a linter plugin, it can be difficult to ensure that these rules are followed, potentially leading to bugs if hooks are used incorrectly.

5. The Provider Pattern in React

The Provider Pattern is a powerful way to manage and share state across multiple components using React's Context API. This pattern is especially useful for handling global states such as themes, user authentication, or application settings, enabling efficient data sharing without the complexity of prop drilling.

5.1. What is the Provider Pattern?

The Provider Pattern utilises a context provider to distribute data to multiple components within an application. The provider component holds the context value and makes it available to any descendant components that need it. This approach eliminates the need to pass props through every level of the component tree, simplifying state management and improving code readability.

5.2. Example Scenario

Imagine we want to add a theme toggle to our landing page, allowing users to switch between light mode and dark mode. Several components, such as the navigation bar, listing cards, main section, and toggle button, need to change their styles based on the current theme.
By using the Provider Pattern, we can manage the theme state centrally and share it with any component that needs it.
First, we create a context for the theme:

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

// Create the Theme Context
const ThemeContext = createContext();

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

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

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

// Custom hook for using the theme context
const useTheme = () => useContext(ThemeContext);

export { ThemeProvider, useTheme };

Now, we can use the useTheme hook in our components to access the theme context.

import React from 'react';
import { ThemeProvider, useTheme } from './ThemeContext';

// Example components

const TopNav = () => {
  const { theme } = useTheme();
  return <nav className={theme}>Top Navigation</nav>;
};

const ListingCard = () => {
  const { theme } = useTheme();
  return <div className={`card ${theme}`}>Listing Card</div>;
};

const MainSection = () => {
  const { theme } = useTheme();
  return <main className={theme}>Main Section</main>;
};

const ThemeToggle = () => {
  const { toggleTheme } = useTheme();
  return <button onClick={toggleTheme}>Toggle Theme</button>;
};

// App component
const App = () => (
  <ThemeProvider>
    <TopNav />
    <ThemeToggle />
    <MainSection />
    <ListingCard />
  </ThemeProvider>
);


export default App;

5.3. Tradeoffs

+ Scalability

  • The Provider Pattern simplifies state management across multiple components. As the application grows, you can easily rename values or restructure components without worrying about deeply nested prop drilling.
  • Components can be more modular and reusable, as they rely on context rather than props passed down through many layers.

+ Avoids Prop-Drilling

  • Before the Context API, sharing data across multiple components required prop drilling, which is when props are passed down through many layers of the component tree. This approach can become complex and hard to manage, especially in large applications.
  • The Provider Pattern eliminates the need for prop drilling, making it easier to pass data to multiple components and improving maintainability.

- Performance

  • Components that consume the context will re-render whenever the context value changes. This can lead to performance issues if not managed properly.
  • It's important to be mindful of which components consume the context to avoid unnecessary re-renders. For performance-critical applications, consider splitting contexts or using memoization to optimize re-renders.

6. The Compound Pattern in React

The Compound Component Pattern is a design approach that lets you build a collection of components that collaborate to provide a single, cohesive functionality. This pattern is ideal for creating sophisticated UI elements where multiple components need to work in unison, such as forms, dropdowns, or modals.

6.1. What is the Compound Component Pattern?

The Compound Component Pattern entails designing a main component that handles state and behavior while offering sub-components that rely on and interact with this shared state. The primary component manages the overall functionality, and the sub-components can access and modify this state, leading to a modular and integrated component structure.

6.2. Example Scenario

Consider a search input component where clicking on the search input shows a popup with popular locations. We can use the Compound Component Pattern to create a FlyOut component that handles this behavior, exposing sub-components for the input and popup. Parent FlyOut Component: This component manages the state (isOpen) and behavior (toggle) for opening and closing the popup. It also maps through its children to pass the state and toggle function to them.

import React, { useState } from 'react';

const FlyOut = ({ children }) => {
 const [isOpen, setIsOpen] = useState(false);

 const toggle = () => setIsOpen(!isOpen);

 return React.Children.map(children, (child) => {
   if (React.isValidElement(child)) {
     return React.cloneElement(child, { isOpen, toggle });
   }
   return child;
 });
};

Sub-components: These components utilize the state and behavior provided by the FlyOut component.

  • FlyOutButton: A button that toggles the popup state when clicked.
const FlyOutButton = ({ toggle, children }) => (
  <button onClick={toggle}>{children}</button>
);
  • FlyOutContent: A component that displays its children only when the popup is open.
const FlyOutContent = ({ isOpen, children }) => (
  isOpen ? <div className="flyout-content">{children}</div> : null
);
  • FlyOutToggle: A component that displays "Open" or "Close" based on the popup state.
const FlyOutToggle = ({ isOpen }) => (
 <span>{isOpen ? 'Close' : 'Open'}</span>
);

Using the Compound Components: These components are composed together to form the complete functionality.

const SearchComponent = () => (
  <FlyOut>
    <FlyOutButton>
      <FlyOutToggle />
    </FlyOutButton>
    <FlyOutContent>
      <div>Popular Locations:</div>
      <ul>
        <li>Location 1</li>
        <li>Location 2</li>
        <li>Location 3</li>
      </ul>
    </FlyOutContent>
  </FlyOut>
);

const App = () => (
  <div>
    <h1>Search</h1>
    <SearchComponent />
  </div>
);

export default App;

6.3. Tradeoffs

+ State Management

  • Compound components manage their own internal state, which is shared among the child components. This simplifies the parent component's responsibility, as it does not need to manage the state directly.

+ Single Import

  • When using a compound component, you import only the parent component, which internally provides all necessary child components. This reduces the need for multiple imports and keeps the code clean and organized.

- Nested Components

  • When using React.Children.map, only the direct children of the parent component have access to the props provided by the parent. This means you cannot wrap these components in another component without losing access to the provided props.

- Naming Collisions

  • Cloning elements with React.cloneElement performs a shallow merge of props. If a prop passed to the component already exists (e.g., open or toggle), it can cause naming collisions, and the latest value passed will overwrite the existing one.

7. Conclusions

React design patterns enhance the structure and maintainability of applications by promoting best practices for managing state and component logic. While traditional patterns like Container/Presentational and Higher-Order Components (HOC) have their uses, modern patterns such as the Context API with hooks and the Compound Component pattern have become more viable.

The Context API with hooks provides a streamlined way to manage and share state across components, avoiding the complexity of prop drilling. Meanwhile, the Compound Component pattern offers a cohesive way to build modular components that work together seamlessly. These modern patterns simplify code, enhance reusability, and are more aligned with contemporary React development practices.

Tomasz Gajda

Tomasz Gajda

Frontend Developer

With a keen eye for detail and a passion for enhancing user experiences, I bring a unique perspective to web development.

Could use some help with Tech?

Tomasz Gajda

Tomasz Gajda

Frontend Developer

With a keen eye for detail and a passion for enhancing user experiences, I bring a unique perspective to web development.

Could use some help with Tech?

Next Article

Comprehensive guide to web app accessibility

Read article
Next Article

Comprehensive guide to web app accessibility

Read article
Share
Fast Landing Pages
Plugins
Let's start working together!
What we can do
Let's start working together!