Using the Single Responsibility Principle (SRP) in React
A Comparison of Approaches with Code Example
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:
Fetching Products: We define a
fetchProducts
function that uses Axios to fetch product data from a provided URL. This data is then set to ourproducts
state variable usingsetProducts
.Setting up an Effect for Fetching: We use
useEffect
to call ourfetchProducts
function whenever our component mounts.Rating Filter Handler: The
handleRating
function updates ourfilterRate
state variable with the selected rate.Filtering Products: We use
useMemo
to create a new filtered list of products every time ourproducts
orfilterRate
state variables change. This filtered list only includes products with a rating greater thanfilterRate
.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 theProduct
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 theFilter
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 theProduct
andFilter
components, as well as theuseProducts
anduseRateFilter
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.