Back

SOLID Design Principle

Building Rock-Solid React Apps: Embracing the SOLID Principles for Software Development

Welcome to the world of React development, where we craft incredible user interfaces and create dynamic web applications. As a React developer, you’re well aware of the power and flexibility this library offers. However, as your projects grow in complexity, maintaining code becomes increasingly challenging. That’s where the SOLID principles come to the rescue!

In this blog, we will delve into the SOLID principles and explore how they can revolutionize the way you write React code. SOLID is an acronym for five essential design principles—Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. These principles, introduced by Robert C. Martin, have stood the test of time and are widely regarded as the cornerstones of robust, maintainable, and scalable software architecture.

By the end of this article, you’ll not only understand each SOLID principle but also learn how to apply them effectively in your React projects. Are you ready to take your React development skills to the next level? Let’s dive in!

The SOLID principles are a set of five design principles in software development. They are not related to any specific programming language or technology. Instead, they are general guidelines that can be applied to various object-oriented programming languages, including React (JavaScript/TypeScript) and others.

To provide more detail, here’s the breakdown of each SOLID principle:

  1. Single Responsibility Principle (SRP): “A class/component should have only one reason to change.” In simple terms, each class or component should focus on doing just one thing. It should have a single responsibility or concern. This makes the code easier to understand, maintain, and extend.
  2. Open/Closed Principle (OCP): “Software entities (classes/components) should be open for extension but closed for modification.” This means that we should design our code in a way that allows us to add new functionality without modifying the existing code. We achieve this through inheritance, composition, or interfaces, making our code more flexible and less prone to introducing bugs.
  3. Liskov Substitution Principle (LSP): “Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.” In simpler terms, derived classes should be able to replace their base classes without causing unexpected behavior. This ensures that our code is reliable and consistent when using inheritance.
  4. Interface Segregation Principle (ISP): “A class/component should not be forced to depend on interfaces it does not use.” This principle advises that classes should have small, focused interfaces tailored to their specific needs. By doing so, we prevent unnecessary dependencies and make our code more maintainable and easier to work with.
  5. Dependency Inversion Principle (DIP): “High-level modules (components) should not depend on low-level modules (dependencies); both should depend on abstractions.” This principle suggests that we should use dependency injection and interfaces to decouple components from their specific implementations. By doing this, we achieve flexibility and testability, and it becomes easier to change or replace dependencies without affecting the entire codebase.

By adhering to these principles, developers can create more maintainable, scalable, and adaptable software systems, regardless of the programming language or framework being used. React developers can benefit significantly from applying these principles to their component design and overall project structure.

Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) applies to React TypeScript coding just as it does to regular JavaScript. SRP states that a class (or component in the context of React) should have only one reason to change, meaning it should have a single responsibility or concern.

Let’s demonstrate SRP in React with a TypeScript example:

Suppose we have a UserProfileCard component responsible for rendering a user’s profile information and a separate UserProfileFetcher component responsible for fetching the user data from an API. By separating these concerns, we can adhere to the SRP.

// UserProfileFetcher.tsx
import React, { useState, useEffect } from 'react';

type UserProfile = {
  name: string;
  bio: string;
  avatar: string;
};

type UserProfileFetcherProps = {
  userId: number;
  onFetchSuccess: (userProfile: UserProfile) => void;
};

const UserProfileFetcher: React.FC<UserProfileFetcherProps> = ({ userId, onFetchSuccess }) => {
  useEffect(() => {
    // API call to fetch user data based on the given userId
    const fetchUserProfile = async () => {
      try {
        const response = await fetch(`/api/users/${userId}`);
        const data = await response.json();
        onFetchSuccess(data);
      } catch (error) {
        console.error('Error fetching user profile:', error);
      }
    };
    fetchUserProfile();
  }, [userId, onFetchSuccess]);

  return null; // This component doesn't render anything; it just handles the data fetching
};

export default UserProfileFetcher;

In this UserProfileFetcher component, we’ve defined the UserProfileFetcherProps interface to ensure that userId is provided as a prop and onFetchSuccess is a callback function that will be invoked with the user profile data once the API call is successful.

Now, let’s create the UserProfileCard component responsible for rendering the user profile information:

// UserProfileCard.tsx
import React, { useState } from 'react';
import UserProfileFetcher from './UserProfileFetcher';

type UserProfile = {
  name: string;
  bio: string;
  avatar: string;
};

const UserProfileCard: React.FC = () => {
  const [userProfile, setUserProfile] = useState<UserProfile | null>(null);

  const handleFetchSuccess = (data: UserProfile) => {
    setUserProfile(data);
  };

  return (
    <div className="user-profile-card">
      {userProfile ? (
        <>
          <img src={userProfile.avatar} alt={`Profile of ${userProfile.name}`} />
          <h2>{userProfile.name}</h2>
          <p>{userProfile.bio}</p>
        </>
      ) : (
        <p>Loading user profile...</p>
      )}
      <UserProfileFetcher userId={123} onFetchSuccess={handleFetchSuccess} />
    </div>
  );
};

export default UserProfileCard;

In this UserProfileCard component, we are rendering the user profile information based on the userProfile state. We also include the UserProfileFetcher component inside the UserProfileCard, passing the userId and the handleFetchSuccess callback to fetch the user data.

By dividing the responsibilities between the UserProfileCard and UserProfileFetcher components, we follow the Single Responsibility Principle. The UserProfileCard component focuses solely on rendering the user profile, while the UserProfileFetcher component handles the data fetching. This makes the code more maintainable, as changes to data fetching will not impact the rendering logic, and vice versa.

Open/Closed Principle (OCP)

The Open/Closed Principle (OCP) in React TypeScript suggests that software entities, such as classes or components, should be open for extension but closed for modification. In other words, you should be able to extend the behavior of a component without modifying its source code. This principle promotes the use of abstractions and interfaces to enable flexible and non-intrusive changes.

Let’s demonstrate the Open/Closed Principle in React with a TypeScript example:

Suppose we have a Button component that renders a basic button with some styles. We want to be able to extend this component to create specialized buttons without modifying the existing Button component.

// Button.tsx
import React from 'react';

type ButtonProps = {
  text: string;
  onClick: () => void;
  className?: string; // Optional custom class name for styling
};

const Button: React.FC<ButtonProps> = ({ text, onClick, className }) => {
  return (
    <button className={`btn ${className}`} onClick={onClick}>
      {text}
    </button>
  );
};

export default Button;

n the above example, the Button component takes a text prop for the button label, an onClick prop for the click event handler, and an optional className prop for custom styling.

Now, let’s create specialized buttons by extending the Button component without modifying its source code using composition:

// PrimaryButton.tsx
import React from 'react';
import Button from './Button';

type PrimaryButtonProps = {
  text: string;
  onClick: () => void;
};

const PrimaryButton: React.FC<PrimaryButtonProps> = ({ text, onClick }) => {
  return <Button text={text} onClick={onClick} className="btn-primary" />;
};

export default PrimaryButton;
// SecondaryButton.tsx
import React from 'react';
import Button from './Button';

type SecondaryButtonProps = {
  text: string;
  onClick: () => void;
};

const SecondaryButton: React.FC<SecondaryButtonProps> = ({ text, onClick }) => {
  return <Button text={text} onClick={onClick} className="btn-secondary" />;
};

export default SecondaryButton;

In this approach, we create two specialized components, PrimaryButton and SecondaryButton, by extending the behavior of the Button component without modifying its source code. We achieve this by using composition and passing the Button component as a child to our specialized buttons, along with additional props like className.

By doing this, we adhere to the Open/Closed Principle. The Button component is closed for modification; we don’t need to modify its source code to create new types of buttons. Instead, we open it for extension by using it as a child component and adding custom behavior through props like className. This approach keeps the original Button component intact and allows us to create as many specialized buttons as we want, promoting code reusability and maintainability.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) in React TypeScript is a principle that emphasizes the importance of substitutability of derived (child) components for their base (parent) components. In other words, objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. This principle ensures that the behavior of the base component remains intact when using its derived components.

Let’s demonstrate the Liskov Substitution Principle in React with a TypeScript example:

Suppose we have a base component called Shape that defines a common interface for rendering shapes.

// Shape.tsx
import React from 'react';

type ShapeProps = {
  color: string;
};

const Shape: React.FC<ShapeProps> = ({ color }) => {
  const style: React.CSSProperties = {
    width: '100px',
    height: '100px',
    backgroundColor: color,
  };

  return <div style={style}></div>;
};

export default Shape;

Now, let’s create a derived component called Circle that extends the Shape component and adds additional behavior specific to a circle shape.

// Circle.tsx
import React from 'react';
import Shape from './Shape';

type CircleProps = {
  color: string;
  radius: number;
};

const Circle: React.FC<CircleProps> = ({ color, radius }) => {
  const style: React.CSSProperties = {
    width: `${radius * 2}px`,
    height: `${radius * 2}px`,
    backgroundColor: color,
    borderRadius: '50%',
  };

  return <div style={style}></div>;
};

export default Circle;

In this example, the Circle component extends the Shape component and adds a new prop called radius specific to circles. The Circle component is substitutable for the Shape component, as it can be used anywhere the Shape component is used without affecting the correctness of the program.

Now, let’s demonstrate the substitutability of the Circle component for the Shape component in a React application:

// App.tsx
import React from 'react';
import Shape from './Shape';
import Circle from './Circle';

const App: React.FC = () => {
  return (
    <div>
      <h2>Shapes</h2>
      <Shape color="red" />
      <Circle color="blue" radius={50} />
    </div>
  );
};

export default App;

In the App component, we use both the Shape component and the Circle component interchangeably. The Circle component is a subclass of Shape, and according to the Liskov Substitution Principle, we can substitute Circle for Shape without any adverse effects. This ensures that our application remains correct and functional even when using derived components.

By adhering to the Liskov Substitution Principle, we create components that are more flexible and interchangeable, allowing us to build complex applications with a strong foundation of reusable and extendable components.

Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) in React TypeScript emphasizes that a class (or a component in the context of React) should not be forced to depend on interfaces it does not use. In other words, it suggests that classes should have small, focused interfaces, tailored to the specific needs of each class or component. This principle encourages the use of multiple small interfaces rather than a single large, all-encompassing interface.

Let’s demonstrate the Interface Segregation Principle in React with a TypeScript example:

Suppose we have a UserCard component that displays information about a user, such as their name and avatar. Additionally, we have an interface for the user data that includes various properties, some of which may not be required for the UserCard component.

// UserCard.tsx
import React from 'react';

interface User {
  name: string;
  avatar: string;
  bio?: string; // Optional property that the UserCard component doesn't use
  email?: string; // Optional property that the UserCard component doesn't use
}

interface UserCardProps {
  user: User;
}

const UserCard: React.FC<UserCardProps> = ({ user }) => {
  return (
    <div className="user-card">
      <img src={user.avatar} alt={`Profile of ${user.name}`} />
      <h2>{user.name}</h2>
      {/* Additional rendering logic specific to UserCard */}
    </div>
  );
};

export default UserCard;

n the above UserCard component, we use the User interface to define the shape of the user data. However, the UserCard component only needs the name and avatar properties from the User interface. The bio and email properties are optional and not used by the UserCard component.

Now, let’s demonstrate how the Interface Segregation Principle can be applied by creating a more focused interface for the UserCard component:

// UserCard.tsx
import React from 'react';

interface User {
  name: string;
  avatar: string;
}

interface UserCardProps {
  user: User;
}

const UserCard: React.FC<UserCardProps> = ({ user }) => {
  return (
    <div className="user-card">
      <img src={user.avatar} alt={`Profile of ${user.name}`} />
      <h2>{user.name}</h2>
      {/* Additional rendering logic specific to UserCard */}
    </div>
  );
};

export default UserCard;

In this updated UserCard component, we have removed the unnecessary bio and email properties from the User interface to create a more focused interface specific to the needs of the UserCard component. This adheres to the Interface Segregation Principle, as the UserCard component is not forced to depend on interfaces it does not use.

By following the Interface Segregation Principle, we create more maintainable and scalable components in our React applications. Each component’s interface should be tailored to its specific requirements, avoiding unnecessary dependencies and promoting better code organization and readability.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) in React TypeScript suggests that high-level modules (components) should not depend on low-level modules (dependencies); instead, both should depend on abstractions (interfaces or abstract classes). This principle encourages the use of dependency injection and inversion of control to decouple components from their specific implementations and promote flexibility, testability, and maintainability.

Let’s demonstrate the Dependency Inversion Principle in React with a TypeScript example:

Suppose we have a UserService class that handles user-related operations, such as fetching user data from an API.

// UserService.ts
class UserService {
  async fetchUser(userId: string) {
    // Simulate API call to fetch user data
    return Promise.resolve({ id: userId, name: 'John Doe', email: 'john@example.com' });
  }
}

export default UserService;

Next, we have a UserProfileCard component that displays a user’s profile information and uses the UserService to fetch the user data.

// UserProfileCard.tsx
import React, { useEffect, useState } from 'react';
import UserService from './UserService';

type UserProfile = {
  id: string;
  name: string;
  email: string;
};

type UserProfileCardProps = {
  userId: string;
};

const UserProfileCard: React.FC<UserProfileCardProps> = ({ userId }) => {
  const [userProfile, setUserProfile] = useState<UserProfile | null>(null);

  useEffect(() => {
    // Create an instance of UserService
    const userService = new UserService();

    // Fetch user data using the UserService instance
    userService.fetchUser(userId).then((user) => setUserProfile(user));
  }, [userId]);

  return (
    <div className="user-profile-card">
      {userProfile ? (
        <>
          <h2>{userProfile.name}</h2>
          <p>Email: {userProfile.email}</p>
        </>
      ) : (
        <p>Loading user profile...</p>
      )}
    </div>
  );
};

export default UserProfileCard;

In the UserProfileCard component, we directly create an instance of UserService to fetch user data. This approach violates the Dependency Inversion Principle because the UserProfileCard component is tightly coupled to the UserService class.

To adhere to the Dependency Inversion Principle, we can introduce an abstraction (interface) to represent the user service, and then inject the concrete implementation of the service into the component.

// IUserService.ts
interface IUserService {
  fetchUser(userId: string): Promise<UserProfile>;
}

// UserService.ts
import IUserService from './IUserService';

class UserService implements IUserService {
  async fetchUser(userId: string) {
    // Simulate API call to fetch user data
    return Promise.resolve({ id: userId, name: 'John Doe', email: 'john@example.com' });
  }
}

export default UserService;

Now, let’s modify the UserProfileCard component to receive the IUserService as a prop and use dependency injection:

// UserProfileCard.tsx
import React, { useEffect, useState } from 'react';
import IUserService from './IUserService';

type UserProfile = {
  id: string;
  name: string;
  email: string;
};

type UserProfileCardProps = {
  userId: string;
  userService: IUserService; // Injected IUserService instance
};

const UserProfileCard: React.FC<UserProfileCardProps> = ({ userId, userService }) => {
  const [userProfile, setUserProfile] = useState<UserProfile | null>(null);

  useEffect(() => {
    // Fetch user data using the injected IUserService instance
    userService.fetchUser(userId).then((user) => setUserProfile(user));
  }, [userId, userService]);

  return (
    <div className="user-profile-card">
      {userProfile ? (
        <>
          <h2>{userProfile.name}</h2>
          <p>Email: {userProfile.email}</p>
        </>
      ) : (
        <p>Loading user profile...</p>
      )}
    </div>
  );
};

export default UserProfileCard;

With this modification, the UserProfileCard component is no longer coupled to the concrete UserService class. Instead, it depends on the abstraction (IUserService). This allows us to easily swap out the implementation of the user service (e.g., with a mock service for testing) without changing the UserProfileCard component’s code.

By applying the Dependency Inversion Principle, we achieve loose coupling between components and their dependencies, making our code more maintainable, extensible, and testable in React applications.

Anurag Chatterjee
Anurag Chatterjee
http://www.anuragchatterjee.com
I am Anurag Chatterjee, a skilled IT professional with more than a decade of experience in the field of UI/UX development and design. I love designing appealing interfaces, producing wireframes and mock ups, and putting design systems into practice are my primary areas of competence. I also have great programming abilities in modern frameworks like React, Angular, and Node.js.

    This will close in 0 seconds