solid principles in Reactjs

2023-02-02

Solid principles are a set of five principles aimed at making software design maintainable, scalable and easy to modify. These principles can be applied to any object-oriented programming language, including ReactJS. In this blog post, we’ll discuss the importance of the SOLID principles in ReactJS and how they can improve your code.

Single Responsibility Principle (SRP)

The SRP states that a class should have only one reason to change. In ReactJS, this means that components should have only one responsibility. For example, a component that handles both data display and data retrieval should be separated into two different components. This way, changes in one area do not affect the other, making the code more maintainable.

Example: Consider a component called "UserProfile" which is responsible for displaying the user's profile information as well as fetching the data from the API. This violates the SRP as the component has two responsibilities, data retrieval and display. To adhere to the SRP, we can split this component into two separate components, "UserProfileDisplay" and "UserProfileFetcher".

import React, { Component } from "react";

class UserProfile extends Component {
  state = {
    user: {},
    isLoading: true,
    error: null,
  };

  componentDidMount() {
    // fetch user data from API , you can extract it as a separat function
    fetch("https://api.example.com/users/123")
      .then((response) => response.json())
      .then((data) => {
        this.setState({ user: data, isLoading: false });
      })
      .catch((error) => {
        this.setState({ error, isLoading: false });
      });
  }

  render() {
    return (
      <div>
        {this.state.isLoading ? (
          <p>Loading...</p>
        ) : this.state.error ? (
          <p>Error: {this.state.error.message}</p>
        ) : (
          <UserProfileDisplay user={this.state.user} />
        )}
      </div>
    );
  }
}

class UserProfileDisplay extends Component {
  render() {
    return (
      <div>
        <h2>User Profile</h2>
        <p>Name: {this.props.user.name}</p>
        <p>Email: {this.props.user.email}</p>
      </div>
    );
  }
}

export { UserProfile, UserProfileDisplay };

and the code as functionl component

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

const UserProfile = () => {
  const [user, setUser] = useState({});
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // fetch user data from API , you can also separate it as a function
    fetch("https://api.example.com/users/123")
      .then((response) => response.json())
      .then((data) => {
        setUser(data);
        setIsLoading(false);
      })
      .catch((error) => {
        setError(error);
        setIsLoading(false);
      });
  }, []);

  return (
    <div>
      {isLoading ? (
        <p>Loading...</p>
      ) : error ? (
        <p>Error: {error.message}</p>
      ) : (
        <UserProfileDisplay user={user} />
      )}
    </div>
  );
};

const UserProfileDisplay = (props) => {
  return (
    <div>
      <h2>User Profile</h2>
      <p>Name: {props.user.name}</p>
      <p>Email: {props.user.email}</p>
    </div>
  );
};

export { UserProfile, UserProfileDisplay };

Open/Closed Principle (OCP)

The OCP states that a class should be open for extension but closed for modification. In ReactJS, this means that components should be designed in such a way that they can be extended with new functionality, but the original code does not have to be modified. This is achieved by making use of inheritance and composition.

Example: Consider a component called "Form" that can be used to display different types of forms, such as login form or contact form. To adhere to the OCP, we can create a base "Form" component and extend it for each specific form type, such as "LoginForm" and "ContactForm". This way, the base "Form" component remains closed for modification but open for extension.

import React, { Component } from "react";

class Form extends Component {
  render() {
    return (
      <div>
        {/* common form fields and logic */}
        <form onSubmit={this.props.onSubmit}>
          <input
            type="text"
            name="name"
            value={this.props.name}
            onChange={this.props.onChange}
          />
          <input
            type="text"
            name="email"
            value={this.props.email}
            onChange={this.props.onChange}
          />
          {/* specific form fields */}
          {this.props.children}
          <button type="submit">Submit</button>
        </form>
      </div>
    );
  }
}

class LoginForm extends Form {
  render() {
    return (
      <div>
        <h2>Login Form</h2>
        {/* specific form fields */}
        <input
          type="password"
          name="password"
          value={this.props.password}
          onChange={this.props.onChange}
        />
        {/* common form fields and logic */}
        {super.render()}
      </div>
    );
  }
}

class ContactForm extends Form {
  render() {
    return (
      <div>
        <h2>Contact Form</h2>
        {/* specific form fields */}
        <input
          type="text"
          name="subject"
          value={this.props.subject}
          onChange={this.props.onChange}
        />
        <textarea
          name="message"
          value={this.props.message}
          onChange={this.props.onChange}
        ></textarea>
        {/* common form fields and logic */}
        {super.render()}
      </div>
    );
  }
}

export { LoginForm, ContactForm };

And the code as functional component

import React from "react";

const Form = (props) => {
  return (
    <div>
      {/* common form fields and logic */}
      <form onSubmit={props.onSubmit}>
        <input
          type="text"
          name="name"
          value={props.name}
          onChange={props.onChange}
        />
        <input
          type="text"
          name="email"
          value={props.email}
          onChange={props.onChange}
        />
        {/* specific form fields */}
        {props.children}
        <button type="submit">Submit</button>
      </form>
    </div>
  );
};

const LoginForm = (props) => {
  return (
    <div>
      <h2>Login Form</h2>
      {/* specific form fields */}
      <input
        type="password"
        name="password"
        value={props.password}
        onChange={props.onChange}
      />
      {/* common form fields and logic */}
      <Form {...props}>{props.children}</Form>
    </div>
  );
};

const ContactForm = (props) => {
  return (
    <div>
      <h2>Contact Form</h2>
      {/* specific form fields */}
      <input
        type="text"
        name="subject"
        value={props.subject}
        onChange={props.onChange}
      />
      <textarea
        name="message"
        value={props.message}
        onChange={props.onChange}
      ></textarea>
      {/* common form fields and logic */}
      <Form {...props}>{props.children}</Form>
    </div>
  );
};

export { LoginForm, ContactForm };

Liskov Substitution Principle (LSP)

The LSP states that objects of a superclass should be replaceable with objects of a subclass. In ReactJS, this means that components should be designed in such a way that they can be swapped out with other components that provide the same functionality. This makes the code more flexible and scalable.

Example: Consider a component called "Button" and a subclass called "SubmitButton". Both of these components should have the same methods and properties, so that they can be used interchangeably. This way, if we have a component that uses the "Button" component, we can substitute it with the "SubmitButton" component without affecting the functionality.

// Button component
class Button extends React.Component {
  handleClick = (event) => {
    // handle button click
  };

  render() {
    return <button onClick={this.handleClick}>{this.props.children}</button>;
  }
}

// SubmitButton component
class SubmitButton extends Button {
  render() {
    return (
      <button type="submit" onClick={this.handleClick}>
        {this.props.children}
      </button>
    );
  }
}

And the code as functionl component

import React from "react";

function Button(props) {
  const handleClick = () => {
    console.log("Button clicked");
  };

  return <button onClick={handleClick}>{props.text}</button>;
}

function SubmitButton(props) {
  const handleClick = () => {
    console.log("Submit button clicked");
  };

  return <button onClick={handleClick}>{props.text}</button>;
}

function App() {
  return (
    <div>
      <Button text="Click me" />
      <SubmitButton text="Submit" />
    </div>
  );
}

Interface Segregation Principle (ISP)

The ISP states that a class should not be forced to implement interfaces it does not use. In ReactJS, this means that components should only implement the methods and properties that they actually need. This makes the code more efficient and reduces the risk of bugs.

Example: Consider a component called "Notification" that has methods for sending emails, SMS and push notifications. However, not all implementations of this component need to send all three types of notifications. To adhere to the ISP, we can split the "Notification" component into three separate interfaces, "EmailNotification", "SMSNotification", and "PushNotification". This way, a component that only needs to send emails can implement the "EmailNotification" interface without being forced to implement the other methods.

import React, { Component } from "react";

// Define the three separate interfaces as empty objects.
const EmailNotification = {};
const SMSNotification = {};
const PushNotification = {};

// Define the Notification component that implements all three interfaces.
class Notification extends Component {
  sendEmail() {
    console.log("Sending email notification");
  }

  sendSMS() {
    console.log("Sending SMS notification");
  }

  sendPush() {
    console.log("Sending push notification");
  }

  render() {
    return (
      <div>
        <h1>Notification Component</h1>
        <button onClick={() => this.sendEmail()}>Send Email</button>
        <button onClick={() => this.sendSMS()}>Send SMS</button>
        <button onClick={() => this.sendPush()}>Send Push</button>
      </div>
    );
  }
}

// Define a component that only needs to send emails and implements the EmailNotification interface.
class EmailSender extends Component {
  sendEmail() {
    console.log("Sending email notification");
  }

  render() {
    return (
      <div>
        <h1>Email Sender Component</h1>
        <button onClick={() => this.sendEmail()}>Send Email</button>
      </div>
    );
  }
}
Object.assign(EmailSender.prototype, EmailNotification);

// Define a component that only needs to send SMS notifications and implements the SMSNotification interface.
class SMSSender extends Component {
  sendSMS() {
    console.log("Sending SMS notification");
  }

  render() {
    return (
      <div>
        <h1>SMS Sender Component</h1>
        <button onClick={() => this.sendSMS()}>Send SMS</button>
      </div>
    );
  }
}
Object.assign(SMSSender.prototype, SMSNotification);

// Define a component that only needs to send push notifications and implements the PushNotification interface.
class PushSender extends Component {
  sendPush() {
    console.log("Sending push notification");
  }

  render() {
    return (
      <div>
        <h1>Push Sender Component</h1>
        <button onClick={() => this.sendPush()}>Send Push</button>
      </div>
    );
  }
}
Object.assign(PushSender.prototype, PushNotification);

And the code as functional component

import React from "react";

// Define the three separate interfaces as empty objects.
const EmailNotification = {};
const SMSNotification = {};
const PushNotification = {};

// Define the Notification component that implements all three interfaces.
function Notification() {
  function sendEmail() {
    console.log("Sending email notification");
  }

  function sendSMS() {
    console.log("Sending SMS notification");
  }

  function sendPush() {
    console.log("Sending push notification");
  }

  return (
    <div>
      <h1>Notification Component</h1>
      <button onClick={() => sendEmail()}>Send Email</button>
      <button onClick={() => sendSMS()}>Send SMS</button>
      <button onClick={() => sendPush()}>Send Push</button>
    </div>
  );
}

// Define a component that only needs to send emails and implements the EmailNotification interface.
function EmailSender() {
  function sendEmail() {
    console.log("Sending email notification");
  }

  return (
    <div>
      <h1>Email Sender Component</h1>
      <button onClick={() => sendEmail()}>Send Email</button>
    </div>
  );
}
Object.assign(EmailSender.prototype, EmailNotification);

// Define a component that only needs to send SMS notifications and implements the SMSNotification interface.
function SMSSender() {
  function sendSMS() {
    console.log("Sending SMS notification");
  }

  return (
    <div>
      <h1>SMS Sender Component</h1>
      <button onClick={() => sendSMS()}>Send SMS</button>
    </div>
  );
}
Object.assign(SMSSender.prototype, SMSNotification);

// Define a component that only needs to send push notifications and implements the PushNotification interface.
function PushSender() {
  function sendPush() {
    console.log("Sending push notification");
  }

  return (
    <div>
      <h1>Push Sender Component</h1>
      <button onClick={() => sendPush()}>Send Push</button>
    </div>
  );
}
Object.assign(PushSender.prototype, PushNotification);

Dependency Inversion Principle (DIP)

The DIP states that high-level modules should not depend on low-level modules, but both should depend on abstractions. In ReactJS, this means that components should not be tightly coupled to each other, but instead should be connected through an abstract interface. This makes the code more flexible and easier to modify.

Example: Consider a component called "UserList" that displays a list of users. Instead of tightly coupling this component to a specific data source, such as an API, we can create an abstract interface called "DataSource" and make "UserList" dependent on this interface. This way, we can swap out the data source for another implementation, such as a database, without affecting the "UserList" component.

// High-level module
class UserList {
  constructor(dataSource) {
    this.dataSource = dataSource;
    this.users = this.dataSource.getUsers();
  }

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

// Low-level module
class API {
  getUsers() {
    // get users from API
  }
}

// Low-level module
class Database {
  getUsers() {
    // get users from database
  }
}

And the code as functional component, where the UserList component depends on an abstract interface (dataSource), not on a concrete implementation of the low-level modules (API and Database). This allows the UserList component to be reused with different implementations of the data source without affecting its functionality.

// High-level module
const UserList = ({ dataSource }) => {
  const users = dataSource.getUsers();

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

// Low-level module
const API = () => {
  const getUsers = () => {
    // get users from API
  };

  return { getUsers };
};

// Low-level module
const Database = () => {
  const getUsers = () => {
    // get users from database
  };

  return { getUsers };
};

In conclusion, following the SOLID principles in ReactJS can lead to better maintainable, scalable and easy to modify code. By adhering to these principles, you can ensure that your code is well-structured, making it easier for other developers to understand and work with.

Thanks fro reading :)