DEV Community

Cover image for React Design Patterns: Best Practices for Scalable Applications
Arnab Chatterjee for CodeParrot

Posted on • Originally published at codeparrot.ai

React Design Patterns: Best Practices for Scalable Applications

Introduction to React Design Patterns

As React applications grow in size and complexity, maintaining clean, efficient, and scalable code becomes a challenge. React design patterns offer proven solutions to common development problems, enabling developers to build applications that are easier to manage and extend. These patterns promote modularity, code reuse, and adherence to best practices, making them essential tools for any React developer.

In this guide, we’ll explore key React design patterns, such as Container and Presentation Components, Custom Hooks, and Memoization Patterns, with practical examples to demonstrate their benefits. Whether you're a beginner or an experienced developer, this article will help you understand how to use these patterns to improve your workflow and create better React applications.

Container and Presentation Components

The Container and Presentation Components pattern is a widely used design approach in React that separates application logic from UI rendering. This separation helps in creating modular, reusable, and testable components, aligning with the principle of separation of concerns.

  • Container Components: Handle business logic, state management, and data fetching. They focus on how things work.
  • Presentation Components: Handle the display of data and UI. They focus on how things look.

This division makes your codebase more maintainable, as changes in logic or UI can be handled independently without affecting each other.

Benefits of the Pattern

  1. Code Reusability: Presentation components can be reused across different parts of the application.
  2. Improved Testability: Testing logic becomes easier as it’s isolated in container components.
  3. Simplified Maintenance: Changes in logic or UI can be addressed independently, reducing the risk of breaking other parts of the code.

Example: Fetching and Displaying User Data

Here’s how the Container and Presentation Components pattern can be implemented:

Container Component

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

const UserContainer = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch("/api/users")
      .then((response) => response.json())
      .then((data) => {
        setUsers(data);
        setLoading(false);
      })
      .catch(() => setLoading(false));
  }, []);

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

export default UserContainer;
Enter fullscreen mode Exit fullscreen mode

Presentation Component

import React from "react";

const UserList = ({ users, loading }) => {
  if (loading) return <p>Loading...</p>;

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

export default UserList;
Enter fullscreen mode Exit fullscreen mode

In this example:

  • UserContainer fetches user data and passes it, along with the loading state, as props to UserList.
  • UserList focuses solely on rendering the user data.

This pattern enhances clarity, reduces code duplication, and simplifies testing. It’s especially useful for applications where data fetching and UI rendering are frequent and complex.

Custom Hooks for Composition

Custom Hooks enable you to encapsulate reusable logic, making your React code cleaner and more modular. Instead of duplicating logic across multiple components, you can extract it into a hook and use it wherever needed. This improves code reusability and testability while adhering to the DRY (Don’t Repeat Yourself) principle.

Example: Fetch Data Hook

Custom Hook

import { useState, useEffect } from "react";

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

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

  return { data, loading };
};

export default useFetchData;
Enter fullscreen mode Exit fullscreen mode

Using the Hook

import React from "react";
import useFetchData from "./useFetchData";

const Posts = () => {
  const { data: posts, loading } = useFetchData("/api/posts");

  if (loading) return <p>Loading...</p>;
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
};

export default Posts;
Enter fullscreen mode Exit fullscreen mode

In this example, the useFetchData hook encapsulates the data fetching logic, allowing any component to fetch data with minimal boilerplate. Custom hooks are invaluable for simplifying code and ensuring a clean architecture.

State Management with Reducers

When managing complex or grouped states, the Reducer Pattern provides a structured way to handle state transitions. It centralizes state logic into a single function, making state updates predictable and easier to debug. React’s useReducer hook is ideal for implementing this pattern.

Benefits of Reducers

  1. Predictability: State changes are defined explicitly through actions.
  2. Scalability: Suitable for complex state management with multiple dependencies.
  3. Maintainability: Centralized logic simplifies debugging and testing.

Example: Managing Authentication State

Reducer Function

const initialState = { isAuthenticated: false, user: null };

function authReducer(state, action) {
  switch (action.type) {
    case "LOGIN":
      return { ...state, isAuthenticated: true, user: action.payload };
    case "LOGOUT":
      return initialState;
    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

Component Using useReducer

import React, { useReducer } from "react";

const AuthComponent = () => {
  const [state, dispatch] = useReducer(authReducer, initialState);

  const login = () => dispatch({ type: "LOGIN", payload: { name: "John Doe" } });
  const logout = () => dispatch({ type: "LOGOUT" });

  return (
    <div>
      {state.isAuthenticated ? (
        <>
          <p>Welcome, {state.user.name}</p>
          <button onClick={logout}>Logout</button>
        </>
      ) : (
        <button onClick={login}>Login</button>
      )}
    </div>
  );
};

export default AuthComponent;
Enter fullscreen mode Exit fullscreen mode

In this example:

  • The authReducer defines how the state changes based on actions.
  • The AuthComponent uses useReducer to manage the authentication state.

Reducers are particularly effective for handling intricate state logic in scalable applications, promoting clarity and consistency in state management.

Provider Pattern for Context API

The Provider Pattern leverages React’s Context API to share state or functions across components without prop drilling. It wraps components in a context provider, allowing deeply nested components to access shared data.

Benefits

  1. Avoids Prop Drilling: Simplifies passing data through deeply nested components.
  2. Centralized State Management: Easily manage global states like themes or authentication.

Example: Theme Context

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

const ThemeContext = createContext();

const ThemeProvider = ({ children }) => {
  const [theme, setTheme] = useState("light");
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

const useTheme = () => useContext(ThemeContext);

export { ThemeProvider, useTheme };
Enter fullscreen mode Exit fullscreen mode

Using the Context

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

const ThemeToggle = () => {
  const { theme, setTheme } = useTheme();
  return (
    <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
      Switch to {theme === "light" ? "dark" : "light"} mode
    </button>
  );
};

const App = () => (
  <ThemeProvider>
    <ThemeToggle />
  </ThemeProvider>
);

export default App;
Enter fullscreen mode Exit fullscreen mode

Higher-Order Components (HOCs)

Higher-Order Components (HOCs) are functions that take a component and return a new component with added functionality. They allow you to reuse logic across multiple components without modifying their structure.

Benefits

  1. Code Reusability: Share logic like authentication or theming across components.
  2. Encapsulation: Keep enhanced logic separate from the original component.

Example: Authentication HOC

const withAuth = (Component) => (props) => {
  const isAuthenticated = true; // Replace with actual auth logic
  return isAuthenticated ? <Component {...props} /> : <p>Please log in</p>;
};

const Dashboard = () => <div>Welcome to the dashboard</div>;

export default withAuth(Dashboard);
Enter fullscreen mode Exit fullscreen mode

Compound Components

The Compound Components pattern allows you to build a parent component with multiple child components that work together cohesively. This pattern is ideal for creating flexible and reusable UI components.

Benefits

  1. Customizability: Child components can be combined in different ways.
  2. Clarity: Clearly define relationships between parent and child components.

React Design Patterns

Example: Tabs Component

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

const TabsContext = createContext();

const Tabs = ({ children }) => {
  const [activeTab, setActiveTab] = useState(0);
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      {children}
    </TabsContext.Provider>
  );
};

Tabs.Tab = ({ index, label }) => {
  const { activeTab, setActiveTab } = useContext(TabsContext);
  return (
    <button
      style={{ fontWeight: activeTab === index ? "bold" : "normal" }}
      onClick={() => setActiveTab(index)}
    >
      {label}
    </button>
  );
};

Tabs.Panel = ({ index, children }) => {
  const { activeTab } = useContext(TabsContext);
  return activeTab === index ? <div>{children}</div> : null;
};

// Usage
const App = () => (
  <Tabs>
    <Tabs.Tab index={0} label="Tab 1" />
    <Tabs.Tab index={1} label="Tab 2" />
    <Tabs.Panel index={0}>Content for Tab 1</Tabs.Panel>
    <Tabs.Panel index={1}>Content for Tab 2</Tabs.Panel>
  </Tabs>
);

export default App;
Enter fullscreen mode Exit fullscreen mode

Memoization

Memoization is a performance optimization technique to prevent unnecessary re-renders of components or recalculations of values in React.

Techniques

  1. React.memo: Prevents re-rendering of functional components unless their props change.
   const ExpensiveComponent = React.memo(({ value }) => {
     console.log("Rendered");
     return <p>{value}</p>;
   });

   export default ExpensiveComponent;
Enter fullscreen mode Exit fullscreen mode
  1. useMemo: Memoizes the result of a computation, recalculating only when dependencies change.
   const ExpensiveCalculation = ({ number }) => {
     const calculatedValue = React.useMemo(() => number * 2, [number]);
     return <p>{calculatedValue}</p>;
   };

   export default ExpensiveCalculation;
Enter fullscreen mode Exit fullscreen mode
  1. useCallback: Memoizes functions, useful when passing callbacks to child components.
   const Parent = () => {
     const handleClick = React.useCallback(() => console.log("Clicked!"), []);
     return <Child onClick={handleClick} />;
   };

   const Child = React.memo(({ onClick }) => <button onClick={onClick}>Click</button>);
Enter fullscreen mode Exit fullscreen mode

Memoization improves performance in scenarios involving large datasets or complex UI updates, ensuring React apps remain responsive.

Conclusion

Mastering React design patterns is key to building scalable, maintainable, and efficient applications. By applying patterns like Container and Presentation Components, Custom Hooks, and Memoization, you can streamline development, improve code reusability, and enhance performance. Advanced patterns like Higher-Order Components, Compound Components, and the Provider Pattern further simplify complex state management and component interactions.

These patterns are not just theoretical—they address real-world challenges in React development, helping you write clean and modular code. Start incorporating these patterns into your projects to create applications that are robust, easy to scale, and maintainable for the long term. With React design patterns in your toolkit, you’ll be better equipped to tackle any project, no matter how complex.
For more insights, check out the React Design Patterns documentation on Patterns.dev.

Top comments (0)