SOLID Principles for Frontend Engineers

30 Mar 2024Omar Gamal

As frontend developers, we strive to build applications that are not only visually appealing and user-friendly but also maintainable and extensible. One way to achieve this is by adhering to well-established design principles, such as the SOLID principles introduced by Robert C. Martin (Uncle Bob). These principles, originally formulated for object-oriented programming, can be applied to other programming paradigms, including frontend development with frameworks like React.

In this article, we will explore the five SOLID principles and discuss how they can be leveraged to write better frontend code.

Principles of SOLID

SOLID is an acronym that stands for the following five principles:

Let's dive into each principle and explore how it can be applied in the context of React development with code examples.

Single Responsibility Principle (SRP)

The Single Responsibility Principle states that a class or component should have only one reason to change. In React, this principle applies to components, meaning each component should have a single, well-defined responsibility. By adhering to this principle, we can create components that are easier to understand, maintain, and test.

let's take a look at this example below, we have a UserComponent that displays user information and allows the user to edit their name.

const UserComponent = ({ user }) => {
  const [editMode, setEditMode] = useState(false);

  const handleUpdateUser = (event) => {
    // Update user logic
  };

  const toggleEditMode = () => {
    setEditMode(!editMode);
  };

  return (
    <div>
      {editMode ? (
        <div>
          <label>
            Name:
            <input
              type="text"
              name="name"
              value={user.name}
              onChange={handleUpdateUser}
            />
          </label>
        </div>
      ) : (
        <div>
          <h2>{user.name}</h2>
          <p>{user.email}</p>
          <button onClick={toggleEditMode}>Edit</button>
        </div>
      )}
    </div>
  );
};

export default UserComponent;

code above looks simple and straightforward, I'm sure you've seen similar components in many applications. However, this component violates the Single Responsibility Principle because it has two responsibilities:

  1. Rendering user's profile information.
  2. Handling update profile logic.

It becomes more difficult to understand, maintain, and test as the component grows in complexity. So, how can we refactor this component to adhere to the Single Responsibility Principle?

One way to achieve this is by splitting the component into two separate components: UserInformation and UserEditor.

const UserInformation = ({ user, onEdit }) => (
  <div>
    <h2>{user.name}</h2>
    <p>{user.email}</p>
    <button onClick={onEdit}>Edit</button>
  </div>
);

const UserEditor = ({ user, onUpdate, onCancel }) => {
  const handleUpdateUser = (event) => {
    // Update user logic
  };

  return (
    <div>
      <label>
        Name:
        <input
          type="text"
          name="name"
          value={user.name}
          onChange={handleUpdateUser}
        />
      </label>
      <button onClick={onUpdate}>Save</button>
      <button onClick={onCancel}>Cancel</button>
    </div>
  );
};

By splitting the responsibilities into two separate components, we have improved the maintainability and testability of our code. Each component now has a single responsibility, making it easier to reason about and modify in the future.

Open/Closed Principle (OCP)

The Open/Closed Principle states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. In React, this principle applies to components and their state management. By adhering to this principle, you can add new functionality without modifying existing code, reducing the risk of introducing bugs and making your codebase more extensible.

Let's consider a simple example. we are building a design system for our cool new application, one of the core components we have to implement is a Button component. usually we use different types of buttons in our application based on the context, for example, a primary button, a secondary button, and a danger button.

So in our Button component, we want to make it extensible to support different button types (variants), and handle that through props.

const Button = ({ children, onClick, variant, ...props }) => {
  let styles;

  switch (variant) {
    case 'primary':
      styles = {
        backgroundColor: 'blue',
        color: 'white',
      };
      break;
    case 'secondary':
      styles = {
        backgroundColor: 'gray',
        color: 'white',
      };
      break;
    default:
      styles = {
        padding: '8px 16px',
        borderRadius: '4px',
        cursor: 'pointer',
      };
  }

  return (
    <button style={styles} onClick={onClick} {...props}>
      {children}
    </button>
  );
};

In the code above, we have a Button component that accepts a variant prop to determine the button's appearance. This implementation works fine for now, but what if we want to add more button variants in the future? We would have to modify the existing code by adding a new case to the switch statement. This violates the Open/Closed Principle, as the component is not closed for modification. It increases the risk of introducing bugs and makes the codebase harder to maintain and extend.

Then let's refactor it to adhere to the Open/Closed Principle by using a more extensible approach.

const Button = ({ children, onClick, variant, ...props }) => {
  const baseStyles = {
    padding: '8px 16px',
    borderRadius: '4px',
    cursor: 'pointer',
  };

  const variantStyles = {
    primary: {
      backgroundColor: 'blue',
      color: 'white',
    },
    secondary: {
      backgroundColor: 'gray',
      color: 'white',
    },
  };

  const styles = {
    ...baseStyles,
    ...variantStyles[variant],
  };

  return (
    <button style={styles} onClick={onClick} {...props}>
      {children}
    </button>
  );
};

Now if we want to add destractive button variant for example, we can simply add a new entry to the variantStyles object without modifying the existing code.

  const variantStyles = {
    primary: {
      backgroundColor: 'blue',
      color: 'white',
    },
    secondary: {
      backgroundColor: 'gray',
      color: 'white',
    },
    /* New variant */
    destractive: {
      backgroundColor: 'red',
      color: 'white',
    },
  };

Liskov Substitution Principle (LSP)

The LSP, named after computer scientist Barbara Liskov, asserts that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. At its heart, LSP is about ensuring that a subclass can stand in for its superclass without introducing errors or altering expected behavior.

React's component-based architecture lends itself well to the application of LSP, even though React components are not strictly classes in the traditional object-oriented sense.

Component Composition

One way to apply LSP in React is through component composition. By composing components together, you can create new components that exhibit the same behavior as their constituent parts.

Let's consider an example where we have a Card component that displays a card with a title and content.

const Card = ({ title, description, children }) => (
  <div>
    <h3>{title}</h3>
    <p>{description}</p>
    {children}
  </div>
);

Now, let's say we want to create a ProfileCard component that extends the Card component by adding a user profile picture.

const ProfileCard = ({ title, description, children, profilePicture }) => (
  <Card title={title} description={description}>
    {profilePicture && <img src={profilePicture} alt="Profile" />}
    {children}
  </Card>
);

The ProfileCard component extends the functionality of the Card component by adding a profile picture. It adheres to the Liskov Substitution Principle because it can be used in place of the Card component without altering the behavior of the application.

Higher-Order Components (HOCs)

Higher-Order Components (HOCs) offer another avenue for applying LSP in React, allowing for the extension of component functionality.

const withLoadingIndicator = (Component) => ({
  isLoading = false,
  ...props
}) => {
  if (isLoading) {
    return <div>Loading...</div>;
  }

  return <Component {...props} />;
};

In this example, the withLoadingIndicator HOC adds a loading indicator to a component based on the isLoading prop.

const LoadingButton = withLoadingIndicator(Button);

const App = () => {
  const [loading, setLoading] = useState(false);

  return (
    <LoadingButton onClick={handleClick} isLoading={loading}>
      Submit
    </LoadingButton>
  )
};

By wrapping a component with this HOC, you can extend its behavior without modifying the component itself, thus adhering to the Liskov Substitution Principle.

Interface Segregation Principle (ISP)

The Interface Segregation Principle states that clients should not be forced to depend on interfaces they do not use. In React, this principle applies to prop interfaces and component interfaces. By adhering to this principle, we can create components that are more modular and easier to maintain, as other components only depend on the functionality they need.

Let's consider an example where we have a DocumentActions component that provides various controls for interacting with a document, such as printing, downloading, and sharing.

const DocumentActions = ({ onSave, onDownload, onShare }) => (
  <div>
    <button onClick={onSave}>Print</button>
    <button onClick={onDownload}>Download</button>
    <button onClick={onShare}>Share</button>
  </div>
);

Looks good so far, let's say we have different user roles in our application, and we want to show different sets of controls based on the user's role. For example, an admin user might have full access to all controls, while a regular user might only have access to the download and share controls.

const RegularUser = () => (
  <DocumentActions
    onDownload={()=> alert('Downloading...')}
    onShare={()=> alert('Sharing...')}
  />
);

In this example, the RegularUser component only uses the onDownload and onShare props from the DocumentActions component. But now DocumentActions component ended up with unnecessary props that are not used by the RegularUser component, violating the Interface Segregation Principle.

To adhere to the ISP, we can refactor the DocumentActions component into smaller, more focused components that provide specific functionality.

const PrintButton = ({ onClick }) => (
  <button onClick={onClick}>Print</button>
);

const DownloadButton = ({ onClick }) => (
  <button onClick={onClick}>Download</button>
);

const ShareButton = ({ onClick }) => (
  <button onClick={onClick}>Share</button>
);

const DocumentActions = ({ userRole }) => {
  const handlePrint = () => alert('Printing...');
  const handleDownload = () => alert('Downloading...');
  const handleShare = () => alert('Sharing...');

  if (userRole === 'admin') {
    return (
      <div>
        <PrintButton onClick={handlePrint} />
        <DownloadButton onClick={handleDownload} />
        <ShareButton onClick={handleShare} />
      </div>
    );
  }

  return (
    <div>
      <DownloadButton onClick={handleDownload} />
      <ShareButton onClick={handleShare} />
    </div>
  );
};

By breaking down the DocumentActions component into smaller, more focused components, we have adhered to the Interface Segregation Principle.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.

In React, this principle can be applied to component dependencies, such as API calls, state management, and external libraries. By adhering to this principle, we can decouple components from specific implementations, making them more flexible and easier to test.

Suppose we have a component that fetches user data. Instead of having the component directly call a specific API or service, we can define an interface (abstraction) for the user service and inject an implementation using React's context.

  1. Let's define an interface for the user service.
export class UserService {
  async getUser() {
    throw new Error("Method 'getUser()' must be implemented.");
  }
}
  1. Create a concrete implementation of the user service.
export class ApiUserService extends UserService {
  async getUser() {
    const response = await fetch('/api/user');
    const data = await response.json();
    return data;
  }
}
  1. Create a context to provide the user service implementation.
const UserServiceContext = createContext(new ApiUserService());

export const UserServiceProvider = ({ children }) => {
  return (
    <UserServiceContext.Provider value={new ApiUserService()}>
      {children}
    </UserServiceContext.Provider>
  );
};

export const useUserService = () => useContext(UserServiceContext);
  1. Use the user service in a component.
const UserProfile = () => {
  const userService = useUserService();
  const [user, setUser] = useState(null);

  useEffect(() => {
    userService.getUser().then((data) => setUser(data));
  }, [userService]);

  if (!user) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

By following the Dependency Inversion Principle, we have decoupled the UserProfile component from the specific implementation of the user service.

Conclusion

The SOLID principles provide a set of guidelines for writing maintainable, extensible, and testable code. By applying these principles to frontend development, we can create more robust and scalable applications. While the examples in this article focused on React components, the SOLID principles are applicable to other frontend frameworks and libraries as well.

Remember that these principles are not strict rules but rather guidelines to help you write better code. It's essential to strike a balance between adhering to these principles and practical considerations in your projects.

I hope this article has given you a better understanding of the SOLID principles and how they can be applied to frontend development. By incorporating these principles into your workflow, you can build more reliable and maintainable frontend applications.

Happy coding! 🚀