Using the Single Responsibility Principle (SRP) in React

A Comparison of Approaches with Code Example

Using the Single Responsibility Principle (SRP) in React

Setting the context

In the world of software development, one of the key guidelines that helps in writing clean, maintainable code is the Single Responsibility Principle (SRP). In essence, SRP dictates that a class or module should have only one reason to change. Translated to React, a component should ideally do just one thing. If it ends up doing more than that, it should be decomposed into smaller sub-components.

Let's take an example where we are building a simple product filtering system in a React app. We'll showcase a 'bad' approach that doesn't follow SRP and a 'good' approach that adheres to this principle.

The Bad Approach

In the first version of our product filter (bad-approach.tsx), we have a single component that handles data fetching, product filtering based on rating, and rendering of the products. It also manages state related to product filtering. Although it does work, this design isn't ideal for a number of reasons:

  • Code Clarity and Readability: With multiple responsibilities mixed into a single component, the logic becomes harder to follow.

  • Reusability: Because of the tight coupling of the different concerns, reusing part of the component's functionality (like product fetching or filtering) in other parts of our app becomes complicated.

  • Testability: Testing this component becomes a bit complex as we need to consider all the responsibilities it is handling.

import axios from "axios";
import { useEffect, useMemo, useState } from "react";
import { Rating } from "react-simple-star-rating";

const Bad = () => {
  const [products, setProducts] = useState([]);
  const [filterRate, setFilterRate] = useState(1);

  const fetchProducts = async () => {
    const response = await axios.get("https://fakestoreapi.com/products");

    if (response && response.data) setProducts(response.data);
  };

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

  const handleRating = (rate: number) => {
    setFilterRate(rate);
  };

  const filteredProducts = useMemo(
    () => products.filter((product: any) => product.rating.rate > filterRate),
    [products, filterRate]
  );

  return (
    <>
      <p className="text-4xl text-center text-red-600 mb-4">BAD APPROACH!</p>

      <div className="flex flex-col h-full">
        <div className="flex flex-col justify-center items-center">
          <span className="font-semibold">Minimum Rating </span>
          <Rating
            initialValue={filterRate}
            SVGclassName="inline-block"
            onClick={handleRating}
          />
        </div>
        <div className="h-full flex flex-wrap justify-center">
          {filteredProducts.map((product: any) => (
            <div className="w-56 flex flex-col items-center m-2 max-w-sm bg-white rounded-lg shadow-md dark:bg-gray-500 dark:border-gray-700">
              <a href="#">
                <img
                  className="p-8 rounded-t-lg h-48"
                  src={product.image}
                  alt="product image"
                />
              </a>
              <div className="flex flex-col px-5 pb-5">
                <a href="#">
                  <h5 className="text-lg font-semibold tracking-tight text-gray-900 dark:text-white">
                    {product.title}
                  </h5>
                </a>
                <div className="flex items-center mt-2.5 mb-5 flex-1">
                  {Array(parseInt(product.rating.rate))
                    .fill("")
                    .map((_, idx) => (
                      <svg
                        aria-hidden="true"
                        className="w-5 h-5 text-yellow-300"
                        fill="currentColor"
                        viewBox="0 0 20 20"
                        xmlns="http://www.w3.org/2000/svg"
                      >
                        <title>First star</title>
                        <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
                      </svg>
                    ))}

                  <span className="bg-blue-100 text-blue-800 text-xs font-semibold mr-2 px-2.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800 ml-3">
                    {parseInt(product.rating.rate)}
                  </span>
                </div>
                <div className="flex flex-col items-between justify-around">
                  <span className="text-2xl font-bold text-gray-900 dark:text-white">
                    ${product.price}
                  </span>
                  <a
                    href="#"
                    className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
                    // onClick={onAddToCart}
                  >
                    Add to cart
                  </a>
                </div>
              </div>
            </div>
          ))}
        </div>
      </div>
    </>
  );
};

export default Bad;

This is the 'bad' approach, where a single React component handles multiple responsibilities. Here's what it does:

  1. Fetching Products: We define a fetchProducts function that uses Axios to fetch product data from a provided URL. This data is then set to our products state variable using setProducts.

  2. Setting up an Effect for Fetching: We use useEffect to call our fetchProducts function whenever our component mounts.

  3. Rating Filter Handler: The handleRating function updates our filterRate state variable with the selected rate.

  4. Filtering Products: We use useMemo to create a new filtered list of products every time our products or filterRate state variables change. This filtered list only includes products with a rating greater than filterRate.

  5. Rendering the Product List: Lastly, the component returns a JSX structure that maps through filteredProducts and renders product components for each product. Each product component includes product details like image, title, rating, and price.

While this approach works, it's not ideal as it bundles multiple responsibilities into a single component, which can lead to difficulties in understanding, testing, and reusing the code.

The Good Approach

In contrast, let's look at a better approach that uses the Single Responsibility Principle (SRP) to improve the design:

This is the folder structure used to build this approach.

src/
│
├── components/
│   ├── product.tsx   # The Product component responsible for displaying individual products
│   └── filter.tsx    # The Filter component for filtering products by rating
│
├── hooks/
│   ├── useProducts.tsx  # Hook to fetch and manage the products data from API
│   └── useRateFilter.tsx # Hook to manage the state of the rating filter
│
└── pages/
    └── good-approach.tsx  # The main page showcasing the good approach implementation

Here's the purpose of each file in more detail:

  • components/product.tsx: This is the Product component. It is responsible for displaying individual product details like the image, title, rating, price and the add-to-cart button.

  • components/filter.tsx: This is the Filter component. It displays a filter by rating functionality allowing users to filter products based on a minimum rating.

  • hooks/useProducts.tsx: This is a custom React hook which handles fetching and managing the products data from an API.

  • hooks/useRateFilter.tsx: This is a custom React hook which handles the state of the rating filter. This includes setting and retrieving the filter value.

  • pages/good-approach.tsx: This is the main page that integrates all the components and hooks together to showcase the good approach of building this functionality. It imports and uses the Product and Filter components, as well as the useProducts and useRateFilter hooks.

By separating the concerns into individual components and hooks, the code is much easier to manage, understand, and test. This is the essence of the Single Responsibility Principle in practice.

useProducts Hook:

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

const useProducts = () => {
  const [products, setProducts] = useState<any[]>([]);

  const fetchProducts = async () => {
    const response = await axios.get("https://fakestoreapi.com/products");

    if (response && response.data) setProducts(response.data);
  };

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

  return { products };
};

export default useProducts;

This custom React hook is responsible for fetching product data. It does this when the component using the hook is first mounted, thanks to the useEffect call.

useRateFilter Hook:

import { useState } from "react";

const useRateFilter = () => {
  const [filterRate, setFilterRate] = useState(1);

  const handleRating = (rate: number) => {
    setFilterRate(rate);
  };

  return { filterRate, handleRating };
}

export default useRateFilter;

Here we have a custom hook that handles rating filter state. It provides the current filter rate and a function to update it.

Filter Component:

import { Rating } from "react-simple-star-rating";

interface IFilterProps {
  filterRate: number;
  handleRating: (rate: number) => void;
}

export function Filter(props: IFilterProps) {
  const { filterRate, handleRating } = props;

  return (
    <div className="flex flex-col justify-center items-center mb-4">
      <span className="font-semibold">Filter by Minimum Rating 👇</span>
      <Rating
        initialValue={filterRate}
        SVGclassName="inline-block"
        onClick={handleRating}
      />
    </div>
  );
}

The Filter component handles rendering and user interaction for the rating filter. It accepts the current filter rate and a callback to update it as props, then passes these to the Rating component.

Product Component:

interface IProduct {
  id: string;
  title: string;
  price: number;
  rating: { rate: number };
  image: string;
}

interface IProductProps {
  product: IProduct;
}

export function Product(props: IProductProps) {
  const { product } = props;
  const { id, title, price, rating, image } = product;

  return (
    <div className="w-56 flex flex-col items-center m-2 max-w-sm bg-white rounded-lg shadow-md dark:bg-gray-500 dark:border-gray-700">

      <a href="#">
        <img
          className="p-8 rounded-t-lg h-48"
          src={image}
          alt="product image"
        />
      </a>
      <div className="flex flex-col px-5 pb-5">
        <a href="#">
          <h5 className="text-lg font-semibold tracking-tight text-gray-900 dark:text-white">
            {title}
          </h5>
        </a>
        <div className="flex items-center mt-2.5 mb-5 flex-1">
          {Array(Math.trunc(rating.rate))
            .fill("")
            .map((_, idx) => (
              <svg
                aria-hidden="true"
                className="w-5 h-5 text-yellow-300"
                fill="currentColor"
                viewBox="0 0 20 20"
                xmlns="http://www.w3.org/2000/svg"
              >
                <title>First star</title>
                <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
              </svg>
            ))}

          <span className="bg-blue-100 text-blue-800 text-xs font-semibold mr-2 px-2.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800 ml-3">
            {Math.trunc(rating.rate)}
          </span>
        </div>
        <div className="flex flex-col items-between justify-around">
          <span className="text-2xl font-bold text-gray-900 dark:text-white">
            ${price}
          </span>
          <a
            href="#"
            className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
            // onClick={onAddToCart}
          >
            Add to cart
          </a>
        </div>
      </div>
    </div>
  );
}

The Product component handles rendering a single product. It accepts a product object as props and uses this to populate the rendered JSX.

Good Component:

import { Product } from "../components/product";
import { Filter, filterProducts } from "../components/filter";
import useProducts from "../hooks/useProducts";
import useRateFilter from "../hooks/useRateFilter";

const Good = () => {
  const { products } = useProducts();

  const { filterRate, handleRating } = useRateFilter();

  return (
    <>
      <p className="text-4xl text-center text-teal-600 mb-4">GOOD APPROACH!</p>
      <div className="flex flex-col h-full">
        <Filter filterRate={filterRate as number} handleRating={handleRating} />
        <div className="h-full flex flex-wrap justify-center">
          {filterProducts(products, filterRate).map((product: any) => (
            <Product product={product} />
          ))}
        </div>
      </div>
    </>
  );
};

export default Good;

Finally, we have our main Good component that ties all these separate parts together. It uses the two hooks to get the product list and handle the filter rate, then passes these values and functions down to the Filter and Product components as needed.

This good approach, by dividing responsibilities across distinct components and hooks, adheres to the Single Responsibility Principle, leading to more understandable, maintainable, and testable code.

Application View

https://github.com/adeeshsharma/SRP-REACT-DEMO

Did you find this article valuable?

Support Adeesh Sharma by becoming a sponsor. Any amount is appreciated!