If you're a React developer, you probably know how exciting and fun it is to build user interfaces. But as projects grow bigger, things can get messy and hard to maintain. That's where React Design Patterns come in to save the day!

In this article, we're going to cover 11 important design patterns that can make your React code:

Mastering design patterns is the step towards becoming a senior web developer

But before we dive into the list, let's break down what design patterns actually are and why you should care about them.

What is a Design Pattern in Coding?

A design pattern is a tried-and-tested solution to a common coding problem.

A design pattern is a tried-and-tested solution to a common coding problem. Instead of reinventing the wheel every time you write code, you can use a design pattern to solve the issue in a reliable way. Think of it like a blueprint for your code.

These patterns are not code that you copy and paste, but ideas and structures you can use to improve your work. They help developers organize their projects better and avoid common pitfalls.

Think of it like a blueprint for your code.

Why Use Design Patterns in React?

Using design patterns is essential because they:

  1. Make Your Code Easy to Read: Clear patterns mean other developers (or future you) can understand your code faster.
  2. Reduce Bugs: Structured code leads to fewer mistakes.
  3. Boost Efficiency: You don't have to solve the same problems over and over.
  4. Improve Collaboration: Teams can work more effectively with shared patterns.
  5. Scale Better: When your app gets bigger, design patterns keep things from getting chaotic.

You can use design patterns as a benchmark for code quality standards

Now that you know why they matter, let’s get into the 12 React design patterns you should know!

11 React Design Patterns

Design Pattern #1: Container and Presentational Components

This pattern helps you separate the logic of your app (containers) from the display (presentational components). It keeps your code organized and makes each part easier to manage.

What Are Container and Presentational Components?

Purpose

The purpose of this pattern is to separate concerns.

Containers handle logic, while presentational components handle UI.

This makes your code easier to understand, test, and maintain.

Tips

Pros and Cons

Pros βœ…

Cons ❌

Clear separation of logic and UI

Can lead to more files and components

Easier to test (containers and UI separately)

Might feel like overkill for simple apps

Promotes reusable UI components

Best For

Code example

Presentational component

It displays data - that's it.

// UserList.jsx

const UserList = ({ users }) => (
  <ul>
    {users.map(user => (
      <li key={user.id}>{user.name}</li>
    ))}
  </ul>
);

export default UserList;

Container component

It performs a logic - in this case fetching data.

// UserListContainer.jsx

import { useEffect, useState } from 'react';
import UserList from './UserList';

const UserListContainer = () => {
  const [users, setUsers] = useState([]);

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

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

export default UserListContainer;

Design Pattern #2: Custom hooks

Custom hooks allow you to extract and reuse stateful logic in your React components. They help you avoid repeating the same logic across multiple components by packaging that logic into a reusable function.

Why Use Custom Hooks

When components share the same logic (e.g., fetching data, handling form inputs), custom hooks allow you to abstract this logic and reuse it.

Naming Convention

Custom hooks should always begin with use, which follows React's built-in hooks convention (like useState, useEffect).

Example: useDataFetch()

Purpose

The goal of custom hooks is to make your code DRY (Don't Repeat Yourself) by reusing stateful logic. This keeps your components clean, focused, and easier to understand.

Tips

Pros and Cons

Pros βœ…

Cons ❌

Reduces code duplication

Can make the code harder to follow if overused

Keeps components clean and focused

Easy to test and reuse

Best For

Code Example

// useFetch.js
import { useState, useEffect } from 'react';

const useFetch = (url) => {
  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((error) => {
        setError(error);
        setLoading(false);
      });
  }, [url]);

  return { data, loading, error };
};

export default useFetch;

// Component using the custom hook

import useFetch from './useFetch';

const UserList = () => {
  const { data: users, loading, error } = useFetch('https://api.example.com/users');

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

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

export default UserList;

Have you noticed that in this code example we also used Design Pattern #1: Container and Presentational Components 😊

When NOT to Use Custom Hooks

  • If the logic is very specific to one component and unlikely to be reused.
  • If it introduces unnecessary abstraction, making the code harder to understand.

To reuse JSX markup, create a component.

To reuse logic without React hooks, create a utility function

To reuse logic with React hooks, create a custom hook


Design Pattern #3: Compound Components

A compound component in React is a design pattern where a component is composed of several smaller components that work together. The idea is to create a flexible and reusable component system where each subcomponent has its own specific responsibility, but they work together to form a cohesive whole.

It’s like building a set of Lego pieces that are designed to fit together.

Real life example

A good example is the <BlogCard> component. Its typical children include a title, description, image, and a β€œRead More” button. Since the blog consists of multiple pages, you might want to display <BlogCard> differently depending on the context.

For instance, you might exclude the image on a search results page or display the image above the title on another page. One way to achieve this is by using props and conditional rendering.

However, if there are many variations, your code can quickly become clumsy. This is where Compound Components come in handy. 😊

Example Use Cases

Purpose

The purpose of the Compound Component pattern is to give users flexibility in composing UI elements while maintaining a shared state and behavior.

Tips

Pros and Cons

Pros βœ…

Cons ❌

Provides flexibility to compose components

Can be complex for beginners

Keeps related components encapsulated

Harder to understand if components are deeply nested

Best For

Code Example

// ProductCard.jsx

export default function ProductCard({ children }) {
  return (
    <>
      <div className='product-card'>{children}</div>;
    </>
  );
}

ProductCard.Title = ({ title }) => {
  return <h2 className='product-title'>{title}</h2>;
};
ProductCard.Image = ({ imageSrc }) => {
  return <img className='product-image' src={imageSrc} alt='Product' />;
};

ProductCard.Price = ({ price }) => {
  return <p className='product-price'>${price}</p>;
};

ProductCard.Title.displayName = 'ProductCard.Title';
ProductCard.Image.displayName = 'ProductCard.Image';
ProductCard.Price.displayName = 'ProductCard.Price';

// App.jsx

import ProductCard from './components/ProductCard';

export default function App() {
  return (
    <>
      <ProductCard>
        <ProductCard.Image imageSrc='https://via.placeholder.com/150' />
        <ProductCard.Title title='Product Title' />
        <ProductCard.Price price='9.99' />
      </ProductCard>
    </>
  );
}

You can layout inner components in any order πŸ™‚


Design Pattern #4: Prop Combination

The Prop Combination pattern allows you to modify the behavior or appearance of a component by passing different combinations of props. Instead of creating multiple versions of a component, you control variations through the props.

This pattern helps you achieve flexibility and customization without cluttering your codebase with many similar components.

Common Use Cases

Default Values: You can set default values for props to avoid unexpected behavior when no props are provided.

Purpose

The purpose of this pattern is to provide a simple way to create variations of a component without duplicating code. This keeps your components clean and easy to maintain.

Tips

Pros and Cons

Pros βœ…

Cons ❌

Reduces the need for multiple similar components

Can lead to "prop explosion" if overused

Easy to customize behavior and appearance

Complex combinations may become hard to understand

Keeps code DRY (Don't Repeat Yourself)

Best For

Code Example

Let's say you're building a Button component that can vary in style, size, and whether it's disabled:

// Button.jsx

const Button = ({ type = 'primary', size = 'medium', disabled = false, children, onClick }) => {
  let className = `btn ${type} ${size}`;
  if (disabled) className += ' disabled';

  return (
    <button className={className} onClick={onClick} disabled={disabled}>
      {children}
    </button>
  );
};

// App.jsx

import Button from './components/Button';

const App = () => (
  <div>
    <Button type="primary" size="large" onClick={() => alert('Primary Button')}>
      Primary Button
    </Button>

    <Button type="secondary" size="small" disabled>
      Disabled Secondary Button
    </Button>

    <Button type="danger" size="medium">
      Danger Button
    </Button>
  </div>
);

Design Pattern #5: Controlled components

Controlled inputs are form elements whose values are controlled by React state. In this pattern, the form input's value is always in sync with the component's state, making React the single source of truth for the input data.

This pattern is often used for input fields, text areas, checkboxes, and select elements.

The value of the input element is bound to a piece of React state. When the state changes, the input reflects that change.

Controlled vs. Uncontrolled Components:

  • Controlled Components have their value controlled by React state.
  • Uncontrolled Components rely on the DOM to manage their state (e.g., using ref to access values).

Purpose

The purpose of using controlled components is to have full control over form inputs, making the component behavior predictable and consistent. This is especially useful when you need to validate inputs, apply formatting, or submit data dynamically.

Tips

Pros and Cons

Pros βœ…

Cons ❌

Easy to validate and manipulate inputs

Can require more boilerplate code

Makes form elements predictable and easier to debug

May lead to performance issues with very large forms

Full control over user input

Best For

Code Example

import { useState } from 'react';

function MyForm() {
  const [name, setName] = useState('');

  const handleChange = (e) => {
    setName(e.target.value);
  };

  return (
    <form>
      <input 
        type="text" 
        value={name} 
        onChange={handleChange} 
      />
      <p>Your name is: {name}</p>
    </form>
  );
}

Design Pattern #6: Error boundaries

Error Boundaries are React components that catch JavaScript errors in their child component tree during rendering, lifecycle methods, and event handlers. Instead of crashing the entire application, Error Boundaries display a fallback UI to handle errors gracefully.

This pattern is crucial for making React applications more robust and user-friendly.

Purpose

The purpose of Error Boundaries is to prevent an entire application from crashing when a component encounters an error. Instead, they show a user-friendly fallback UI, allowing the rest of the application to remain functional.

Tips

Pros and Cons

Pros βœ…

Cons ❌

Prevents the entire app from crashing

Cannot catch errors in event handlers or asynchronous code

Provides a fallback UI for a better user experience

Helps catch and log errors in production

Best For

Code Example

React has a built in way to use Error Boundary design pattern. But it’s a bit outdated (still uses a class component). A better recommendation would to use a dedicated npm library: react-error-boundary

import { ErrorBoundary } from "react-error-boundary";

<ErrorBoundary fallback={<div>Something went wrong</div>}>
  <App />
</ErrorBoundary>

Design Pattern #7: Lazy Loading (Code Splitting)

Lazy Loading is a technique where components or parts of your app are loaded only when they are needed. Instead of loading everything at once when the app starts, lazy loading helps split the code into smaller chunks and load them on demand. This improves performance by reducing the initial load time of your application.

How Does It Work in React?

React supports lazy loading through the React.lazy() function and Suspense component.

  1. React.lazy(): This function lets you dynamically import a component.
  2. Suspense: Wraps around a lazily loaded component to show a fallback (like a loading spinner) while waiting for the component to load.

Purpose

The purpose of lazy loading is to optimize the application's performance by reducing the initial bundle size. This leads to faster load times, especially for large applications where not all components are needed immediately.

Tips

Pros and Cons

Pros βœ…

Cons ❌

Reduces initial load time

Adds slight delays when loading components

Improves performance for large apps

Requires handling of loading states and errors

Loads code on demand, saving bandwidth

Complexity increases with too many chunks

Best For

Code Example

// Profile.jsx

const Profile = () => {
  return <h2>This is the Profile component!</h2>;
};

export default Profile;

// App.jsx 

import { Suspense, lazy } from 'react';

// Lazy load the Profile component
const Profile = lazy(() => import('./Profile'));

function App() {
  return (
    <div>
      <h1>Welcome to My App</h1>

      {/* Suspense provides a fallback UI while the lazy component is loading */}
      <Suspense fallback={<div>Loading...</div>}>
        <Profile />
      </Suspense>
    </div>
  );
}

export default App;

Design Pattern #8: Higher-Order Component (HOC)

A higher-order component takes in a component as an argument and returns a supercharged component injected with additional data or functionality.

HOCs are often used for logic reuse, such as authentication checks, fetching data, or adding styling.

Signature of an HOC

const EnhancedComponent = withSomething(WrappedComponent);

Tips

Pros and Cons

Pros βœ…

Cons ❌

Promotes code reuse

Can lead to "wrapper hell" (too many nested HOCs)

Keeps components clean and focused on their main task

Harder to debug due to multiple layers of abstraction

Best For

Higher-Order Component (HOC) is an advanced React pattern

Code Example

Here’s an example of a Higher-Order Component that adds a loading state to a component:

// HOC - withLoading.js

// it returns a functional component

const withLoading = (WrappedComponent) => {
  return ({ isLoading, ...props }) => {
    if (isLoading) {
      return <div>Loading...</div>;
    }
    return <WrappedComponent {...props} />;
  };
};

export default withLoading;

// DataComponent.js

const DataComponent = ({ data }) => {
  return <div>Data: {data}</div>;
};

export default DataComponent;

// App.js

import { useState, useEffect } from 'react';
import withLoading from './withLoading';
import DataComponent from './DataComponent';

// supercharching with HOC
const DataComponentWithLoading = withLoading(DataComponent);

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

  useEffect(() => {
    setTimeout(() => {
      setData('Here is the data!');
      setLoading(false);
    }, 2000);
  }, []);

  return (
    <div>
      <h1>My App</h1>
      <DataComponentWithLoading isLoading={loading} data={data} />
    </div>
  );
};

export default App;

Design Pattern #9: State Management with Reducers

When the app’s state is more complex instead of using useState to manage your application's state, you can use reducers.

Reducers allow you to handle state transitions in a more predictable and organized way.

A reducer is simply a function that takes the current state and an action, then returns the new state.

Basics

Reducer Function: A pure function that takes state and action as arguments and returns a new state.

const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    default:
      return state;
  }
};

Action: An object that describes what kind of state update should happen. Actions usually have a type field and may include additional data (payload).

Dispatch: A function used to send actions to the reducer, triggering a state update.

Purpose

This pattern is useful when the state logic becomes too complex for useState. It centralizes state updates, making your code easier to manage, debug, and scale.

Tips

Pros and Cons

Pros βœ…

Cons ❌

Simplifies complex state logic

Adds boilerplate code (actions, dispatch, etc.)

Centralizes state updates for easier debugging

Can be overkill for simple state management

Makes state transitions predictable

Requires learning curve for beginners

Best For

Code Example

Here’s an example of state management with useReducer in a counter app:

import { useReducer } from 'react';

// Step 1: Define the reducer function
const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    case 'RESET':
      return { count: 0 };
    default:
      return state;
  }
};

// Step 2: Define the initial state
const initialState = { count: 0 };

// Step 3: Create the component
const Counter = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <h1>Count: {state.count}</h1>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>Increment</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>Decrement</button>
      <button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
    </div>
  );
};

export default Counter;

In modern React development, Redux is the library that uses reducers for state management.

Design Pattern #10: Data management with Providers (Context API)

The provider pattern is very useful for data management as it utilizes the context API to pass data through the application's component tree. This pattern is an effective solution to prop drilling, which has been a common concern in react development.

Context API is the solution to prop drilling

Providers allow you to manage global state in a React application, making it accessible to any component that needs it.

This pattern helps avoid prop drilling (passing props through many layers) by offering a way to "provide" data to a component tree.

Basics

Purpose

The purpose of this pattern is to simplify data sharing between deeply nested components by creating a global state accessible via a Provider. It helps keep code clean, readable, and free of unnecessary prop passing.

Tips

Pros and Cons

Pros βœ…

Cons ❌

Reduces prop drilling

Not ideal for frequently changing data (can cause unnecessary re-renders)

Centralizes data for easier access

Performance issues if context value changes often

Simple to set up for small to medium-sized apps

Best For

Code Example

Here’s an example of data management with a ThemeProvider:

// ThemeContext.jsx

import { createContext, useState } from 'react';

export const ThemeContext = createContext();

export 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>
  );
};

// ThemeToggleButton.jsx

import { useContext } from 'react';
import { ThemeContext } from './ThemeContext';

const ThemeToggleButton = () => {
  const { theme, toggleTheme } = useContext(ThemeContext);

  return (
    <button onClick={toggleTheme}>
      Switch to {theme === 'light' ? 'dark' : 'light'} mode
    </button>
  );
};

export default ThemeToggleButton;

// App.js

import { ThemeProvider } from './ThemeContext';
import ThemeToggleButton from './ThemeToggleButton';

const App = () => {
  return (
    <ThemeProvider>
      <div>
        <h1>Welcome to the App</h1>
        <ThemeToggleButton />
      </div>
    </ThemeProvider>
  );
};

export default App;

In React 19, you can render <Context> as a provider instead of <Context.Provider>

const ThemeContext = createContext('');

function App({children}) {
  return (
    <ThemeContext value="dark">
      {children}
    </ThemeContext>
  );  
}

Design Pattern #11: Portals

Portals allow you to render children into a different part of the DOM tree that exists outside the parent component's hierarchy.

This is useful for rendering elements like modals, tooltips, or overlays that need to be displayed outside the normal DOM flow of the component.

Even though the DOM parent changes, the React component structure stays the same.

Purpose

The purpose of this pattern is to provide a way to render components outside the parent component hierarchy, making it easy to manage certain UI elements that need to break out of the flow, without disrupting the structure of the main React tree.

Tips

Pros and Cons

Pros βœ…

Cons ❌

Keeps the component tree clean and avoids layout issues

Can complicate event propagation (e.g., click events may not bubble)

Best For

Code Example

// Modal.jsx

import { useEffect } from 'react';
import ReactDOM from 'react-dom';

const Modal = ({ isOpen, closeModal, children }) => {
  // Prevent body scrolling when modal is open
  useEffect(() => {
    if (isOpen) {
      document.body.style.overflow = 'hidden';
    }
    return () => {
      document.body.style.overflow = 'unset';
    };
  }, [isOpen]);

  if (!isOpen) return null;

  return ReactDOM.createPortal(
    <>
      {/* Overlay */}
      <div 
        style={overlayStyles} 
        onClick={closeModal}
      />
      {/* Modal */}
      <div style={modalStyles}>
        {children}
        <button onClick={closeModal}>Close</button>
      </div>
    </>,
    document.getElementById('modal-root')
  );
};

const overlayStyles = {
  ...
};

const modalStyles = {
  ...
};

export default Modal;

// App.js

import { useState } from 'react';
import Modal from './Modal';

const App = () => {
  const [isModalOpen, setIsModalOpen] = useState(false);
  
  return (
    <div>
      <h1>React Portals Example</h1>
      <button onClick={() => setIsModalOpen(true)}>Open Modal</button>
      <Modal isOpen={isModalOpen} closeModal={() => setIsModalOpen(false)}>
        <h2>Modal Content</h2>
        <p>This is the modal content</p>
      </Modal>
    </div>
  );
};

export default App;

// index.html

<body>
  <div id="root"></div>
  <div id="modal-root"></div>
</body>

What’s in the end?

Learning and mastering design patterns is a crucial step toward becoming a senior web developer. πŸ†™

These patterns are not just theoretical; they address real-world challenges like state management, performance optimization, and UI component architecture.

By adopting them in your everyday work, you'll be equipped to solve a variety of development challenges and create applications that are both performant and easy to maintain.

Liked the article? 😊

You can learn more at my personal Javascript blog ➑️ https://jssecrets.com/.

Or you can see my projects and read case studies at my personal website ➑️ https://ilyasseisov.com/.

Happy coding! 😊