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.
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.
Using design patterns is essential because they:
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!
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.
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.
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 |
|
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;
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.
When components share the same logic (e.g., fetching data, handling form inputs), custom hooks allow you to abstract this logic and reuse it.
Custom hooks should always begin with use
, which follows React's built-in hooks convention (like useState
, useEffect
).
Example: useDataFetch()
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.
useState
, useEffect
, or even other custom hooks.
Pros ✅ |
Cons ❌ |
---|---|
Reduces code duplication |
Can make the code harder to follow if overused |
Keeps components clean and focused |
|
Easy to test and reuse |
|
// 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
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
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.
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. 😊
The purpose of the Compound Component pattern is to give users flexibility in composing UI elements while maintaining a shared state and behavior.
Pros ✅ |
Cons ❌ |
---|---|
Provides flexibility to compose components |
Can be complex for beginners |
Keeps related components encapsulated |
Harder to understand if components are deeply nested |
// 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 🙂
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.
Default Values: You can set default values for props to avoid unexpected behavior when no props are provided.
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.
isPrimary
, isDisabled
).
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) |
|
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>
);
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:
ref
to access values).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.
onChange
Events: Always update state through the onChange
event handler to keep the input value in sync with the state.undefined
inputs.
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 |
|
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>
);
}
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.
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.
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 |
|
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>
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.
React supports lazy loading through the React.lazy()
function and Suspense
component.
React.lazy()
: This function lets you dynamically import a component.Suspense
: Wraps around a lazily loaded component to show a fallback (like a loading spinner) while waiting for the component to load.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.
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 |
// 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;
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.
const EnhancedComponent = withSomething(WrappedComponent);
WrappedComponent
: The original component that is being enhanced.
EnhancedComponent
: The new component returned by the HOC.
with
prefix, such as withAuth
, withLogging
, or withLoading
.WrappedComponent
.WrappedComponent
to ensure it receives everything it needs.
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 |
Higher-Order Component (HOC) is an advanced React pattern
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;
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.
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.
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.
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 |
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.
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.
Provider
.useContext
Hook: A way to access context values without needing a Consumer
.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.
useReducer
for more control.
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 |
|
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>
);
}
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.
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.
Pros ✅ |
Cons ❌ |
---|---|
Keeps the component tree clean and avoids layout issues |
Can complicate event propagation (e.g., click events may not bubble) |
// 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>
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.
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! 😊