Exploring Common Design Patterns in React

Exploring Common Design Patterns in React

with easy-to-follow samples

Introduction

Design patterns play a crucial role in building well-structured and maintainable applications. React, being a popular JavaScript library for building user interfaces, provides developers with flexibility and freedom to choose from various design patterns. In this article, we will explore some common design patterns used in React applications and understand their purposes with practical examples.

On a daily-basis-code-rush to get the job done, it is usual to get over these patterns or just use them without notice.

The purpose of this article is to give an overview of what I consider (which means this is an opinionated article 😅, there are plenty more patterns) are the most used patterns in React.

Component Pattern

The Component Pattern is the foundation of React development, promoting reusability and modularity. It involves decomposing the UI into reusable, modular components. Components in React can be functional or class-based. They encapsulate their logic and rendering, making it easier to manage and maintain the application's UI.

// Button Component
const Button = ({ onClick, children }) => {
  return <button onClick={onClick}>{children}</button>;
};

// App Component
const App = () => {
  const handleClick = () => {
    console.log('Button clicked!');
  };

  return <Button onClick={handleClick}>Click Me</Button>;
};
💡
Pro Tip: Nowadays, using functional composition in React is highly recommended. Only in rare cases would class-based components be useful, as most of the React lifecycle methods can be applied within useEffect for versions 16.8 and above.

Render Props

The Render Props pattern allows components to share functionality through a common interface. It involves passing a function as a prop to a component, which allows the component to render the result of that function. This pattern enables code reuse and flexibility in building components.

// MouseTracker Component
const MouseTracker = ({ render }) => {
  const handleMouseMove = (event) => {
    const { clientX, clientY } = event;
    render(clientX, clientY);
  };

  return <div onMouseMove={handleMouseMove}>Move the mouse!</div>;
};

// MouseCoordinatesDisplay Component
const MouseCoordinatesDisplay = () => {
  return (
    <MouseTracker
      render={(x, y) => (
        <p>
          Mouse coordinates: {x}, {y}
        </p>
      )}
    />
  );
};

Container/Component Pattern

The Container/Component Pattern separates data logic (container components) from UI rendering (UI components). Container components handle data fetching, state management, and business logic, while UI components focus on rendering the UI based on props. This pattern promotes better separation of concerns and code maintainability.

// DataContainer Component
const DataContainer = () => {
  const [data, setData] = useState([]);

  useEffect(() => {
    fetchData()
      .then((response) => setData(response.data))
      .catch((error) => console.log(error));
  }, []);

  return <DataComponent data={data} />;
};

// UIComponent
const UIComponent = ({ data }) => {
  return (
    <div>
      {data.map((item) => (
        <p key={item.id}>{item.name}</p>
      ))}
    </div>
  );
};

Higher-Order Component (HOC) - Decorator Pattern

Higher-Order Components (HOC) are functions that take a component and return an enhanced version of that component. HOCs enable the addition of extra behavior or modification of the rendering of the wrapped component. They provide a way to share common functionality among multiple components.

// withAuthentication HOC
const withAuthentication = (WrappedComponent) => {
  const isAuthenticated = true; // Logic to determine authentication status

  return (props) => {
    if (isAuthenticated) {
      return <WrappedComponent {...props} />;
    } else {
      return <p>Please log in to access this component.</p>;
    }
  };
};

// MyComponent
const MyComponent = () => {
  return <div>Authenticated component</div>;
};

export default withAuthentication(MyComponent);

Context API

The Context API in React provides a way to share state and data across multiple components without explicitly passing props. It eliminates the need for prop drilling and simplifies the management of global or application-level state.

// ThemeContext
const ThemeContext = createContext('light');

// ThemeProvider Component
const ThemeProvider = ({ children }) => {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>;
};

// ThemeComponent Component
const ThemeComponent = () => {
  const theme = useContext(ThemeContext);
  return <div>Current theme: {theme}</div>;
};

Singleton Pattern

The Singleton Pattern ensures that only one instance of a component or service is created and shared throughout the application. It provides global access to a single instance, preventing multiple instances from being created.

import axios from 'axios';

const apiClient = (() => {
  let instance = null;

  const createInstance = () => {
    const api = axios.create({
      baseURL: 'https://api.example.com',
      Authorization: `<Your Auth Token>`,
      Content-Type: "application/json",
      timeout : 1000,
      withCredentials: true
    });

    return api;
  };

  return {
    getInstance: () => {
      if (!instance) {
        instance = createInstance();
      }
      return instance;
    },
  };
})();

export default apiClient;

In this example, the apiClient encapsulates API functionality using axios and is implemented as a Singleton. The static getInstance method ensures only one instance is created and shared throughout the application. This centralized API client promotes code consistency and prevents resource duplication.

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

const UserList = () => {
  const [users, setUsers] = useState([]);
  const [newUser, setNewUser] = useState('');

  const fetchUsers = () => {
    const api = apiClient.getInstance();

    api.get('/users')
      .then((response) => setUsers(response.data))
      .catch((error) => console.error(error));
  };

  const handleAddUser = () => {
    const api = apiClient.getInstance();

    api.post('/users', { name: newUser })
      .then(() => {
        setNewUser('');
        fetchUsers();
      })
      .catch((error) => console.error(error));
  };

  useEffect(() => {
    fetchUsers();
  }, []);

  return (
    <div>
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
      <div>
        <input
          type="text"
          value={newUser}
          onChange={(e) => setNewUser(e.target.value)}
          placeholder="Enter a new user"
        />
        <button onClick={handleAddUser}>Add User</button>
      </div>
    </div>
  );
};

export default UserList;

In this example, the GET and POST methods use a single instance of the axios library, ensuring resource efficiency and consistent API functionality across the application.

Proxy Pattern

The Proxy Pattern controls access to a component and allows for additional logic or behavior before accessing or modifying it. It acts as an intermediary between the client and the component, providing additional functionality.

const ProxyComponent = ({ valid, children }) => {
  if (valid) {
    return <ActualComponent>{children}</ActualComponent>;
  } else {
    return <p>Invalid component</p>;
  }
};

You probably have had a Deja vu right now, it is quite similar to the approach used in the Higher-Order Components section right? In this case, we just isolated the Proxy pattern for demonstration purposes, but you are now starting to get the big picture!

Factory Pattern

The Factory Pattern provides a way to create instances of components or objects dynamically based on certain conditions or configurations. It encapsulates the creation logic and allows for flexible object creation.

import React from 'react';

// Factory function to create notification components
const createNotificationComponent = (type) => {
  switch (type) {
    case 'email':
      return ({ message }) => <div>Email notification: {message}</div>;
    case 'sms':
      return ({ message }) => <div>SMS notification: {message}</div>;
    case 'push':
      return ({ message }) => <div>Push notification: {message}</div>;
    default:
      throw new Error(`Unsupported notification type: ${type}`);
  }
};

// NotificationFactory component
const NotificationFactory = ({ type, message }) => {
  const NotificationComponent = createNotificationComponent(type);

  return <NotificationComponent message={message} />;
};

// Usage example
const App = () => {
  return (
    <div>
      <NotificationFactory type="email" message="New email received" />
      <NotificationFactory type="sms" message="New SMS received" />
      <NotificationFactory type="push" message="New push notification" />
    </div>
  );
};

export default App;

Memoization Pattern

The Memoization Pattern involves caching the result of a component's rendering to improve performance by preventing unnecessary re-rendering. It is useful when a component's rendering depends on expensive computations or when its props do not change frequently.

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

// Expensive Fibonacci calculation function
const fibonacci = (n) => {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
};

// FibonacciComponent
const FibonacciComponent = ({ n }) => {
  const memoizedFibonacci = useMemo(() => fibonacci(n), [n]);

  return <div>The Fibonacci number at index {n} is {memoizedFibonacci}</div>;
};

// App
const App = () => {
  const [index, setIndex] = useState(10);

  return (
    <div>
      <FibonacciComponent n={index} />
      <button onClick={() => setIndex(index + 1)}>Increase Index</button>
    </div>
  );
};

export default App;

Lazy Loading Pattern

A lazy Loading Pattern is a design technique used in software development where the initialization or loading of resources, such as data or objects, is delayed until they are needed, improving performance and reducing the initial load time.

💡
Pro Tip: In React 18, the introduction of the Suspense component, enables developers to implement the Lazy Loading Pattern, deferring resource-intensive tasks and improving performance and initial load times for a seamless user experience.
import React, { lazy, Suspense } from 'react';

const LazyLoadedComponent = lazy(() => import('./LazyLoadedComponent'));

const App = () => {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LazyLoadedComponent />
    </Suspense>
  );
};

Observer Pattern

The Observer Pattern in React is a design pattern that allows an object (called the subject) to maintain a list of its dependents (called observers) and automatically notify them of any state changes, enabling a seamless user experience.

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

// ChatService
const ChatService = {
  listeners: [],
  notify(message) {
    this.listeners.forEach((listener) => listener(message));
  },
  subscribe(listener) {
    this.listeners.push(listener);
  },
  unsubscribe(listener) {
    this.listeners = this.listeners.filter((l) => l !== listener);
  },
};

// ChatRoom component
const ChatRoom = () => {
  const [messages, setMessages] = useState([]);
  const [newMessage, setNewMessage] = useState('');

  useEffect(() => {
    const handleNewMessage = (message) => {
      setMessages((prevMessages) => [...prevMessages, message]);
    };

    ChatService.subscribe(handleNewMessage);

    return () => {
      ChatService.unsubscribe(handleNewMessage);
    };
  }, []);

  const handleSendMessage = () => {
    const message = { text: newMessage, timestamp: Date.now() };
    ChatService.notify(message);
    setNewMessage('');
  };

  return (
    <div>
      <ul>
        {messages.map((message, index) => (
          <li key={index}>{message.text}</li>
        ))}
      </ul>
      <input
        type="text"
        value={newMessage}
        onChange={(e) => setNewMessage(e.target.value)}
      />
      <button onClick={handleSendMessage}>Send</button>
    </div>
  );
};

// Notification component
const Notification = ({ message }) => {
  useEffect(() => {
    console.log(`New message: ${message.text}`);
  }, [message]);

  return null;
};

// App component
const App = () => {
  return (
    <div>
      <ChatRoom />
      <Notification />
    </div>
  );
};

export default App;

Here is a complete functional example of this pattern for better understanding. Feel free to check it out - just type any message and hit enter, it should update both the Notification and Message panel components at the same time, since they are both subscribed and listening for changes 🕵️‍♂️

Error Boundary Pattern

Error Boundary Pattern is a design pattern in React used to catch and handle errors in components, preventing the entire application from crashing and displaying a fallback UI when an error occurs.

import React, { useState } from 'react';

// ErrorBoundary component
const ErrorBoundary = ({ children }) => {
  const [hasError, setHasError] = useState(false);

  const handleOnError = () => {
    setHasError(true);
  };

  if (hasError) {
    return <div>Oops! Something went wrong.</div>;
  }

  return <div onError={handleOnError}>{children}</div>;
};

// ErrorProneComponent
const ErrorProneComponent = () => {
  // Simulating an error
  throw new Error('Error occurred in ErrorProneComponent');

  return <div>ErrorProneComponent</div>;
};

// App component
const App = () => {
  return (
    <ErrorBoundary>
      <ErrorProneComponent />
    </ErrorBoundary>
  );
};

export default App;
💡
Pro Tip: This pattern is built into the Next.js framework to catch boundary errors and prevent the entire UI from breaking.

Final Thoughts

In this article, we explored some common design patterns used in React applications.

These design patterns, such as the Component Pattern, Render Props, Container/Component Pattern, Higher-Order Components (HOC), Context API, and more, provide developers with strategies and best practices for building robust and scalable React applications. Understanding and effectively applying these design patterns can significantly improve code organization, reusability, and maintainability.

Remember, the choice of design pattern depends on the specific requirements and structure of your application. Experiment with these patterns and choose the ones that best suit your needs. Happy coding!

Hope you enjoyed this article, if so, consider giving it a like 👍🏻 and Stay Awesome!

Did you find this article valuable?

Support Alain Iglesias by becoming a sponsor. Any amount is appreciated!