Applying SOLID Principles in React

Learn more about the SOLID principles with examples

Software developer learning about SOLID principles in React.

Principles are general and universal rules that we apply in every aspect of our daily lives because we have learned them over time.

But is there something similar that we can apply in software development?

The answer is yes. There are principles that are specifically applied in software development, and in this case, we refer to the SOLID principles and how they can be applied in React. SOLID is an acronym that describes the first five principles of Robert C. Martin’s Object-Oriented Design (OOD), one of my favorite authors in the programming world.

But why are SOLID principles in React relevant?

These principles establish best practices in software development, taking into account the maintainability and scalability as the project grows. These practices are the result of years of experience in the industry, where mistakes have been made and conceptualized to avoid repeating them in the future. Adopting these best practices can help avoid problems in the code and poor designs, as well as help with the refactoring process.

Each letter in the SOLID acronym represents a specific principle. These principles are as follows:

  • Single responsibility principle (SRP)
  • Open-closed principle (OCP)
  • Liskov substitution principle (LSP)
  • Interface segregation principle (ISP)
  • Dependency inversion principle (DIP)

Disclaimer

As we mentioned at the beginning, these design principles were created with object-oriented programming in mind. As a result, their explanation is largely based on concepts of classes or interfaces, neither of which JS actually has. Furthermore, React is rooted in a functional paradigm instead of an object-oriented paradigm. In this post, we will explore how to translate these principles to React with the goal of applying them in daily development and improving the maintainability of the code in the long run.

Single responsibility principle

This principle states that “a class should only have a single responsibility, that is, only changes to one part of the software’s specification should be able to affect the specification of the class.” [1]

In React, this principle can be easily applied through components. If a component has multiple responsibilities, then it is violating SRP.

Violating SRP can lead to a greater likelihood of errors, as any change in one responsibility can affect others without us realizing. The aim of this principle is to separate responsibilities to minimize the impact of changes and errors in other areas of the software that are unrelated.

To illustrate this principle, consider the following simple code, which is responsible for fetching and displaying recipes:

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


const Recipes = () => {
 const [recipes, setRecipes] = useState([]);
 const [isLoading, setIsLoading] = useState(true);


 useEffect(() => {
   axios.get("https://api.spoonacular.com/recipes/").then((res) => {
       setRecipes(res.data)
   }).catch(e => {
       errorToast(e.message);
   }).finally(() => {
       setIsLoading(false);
   })
 }, [])


 if (isLoading){
   return <div>Loading...</div>
 }


 return (
   <ul>
       {recipes.map(recipe => {
           return (
               <li>
                  <div>
                       <img src={recipe.img} />
                       <h3>{recipe.name}</h3>
                       <span>{recipe.description}</span>
                  </div>
               </li>
           )
       })}
   </ul>
 );
};


export default Recipes;

				
			

When we first look at the code, our initial impression is that it is well-written, as it is usually what we are accustomed to (myself included). However, as we start looking at the component definition, we begin to see certain “problems.” We see that the Recipes component has different responsibilities:

  1. It has the responsibility of managing the state.
  2. It has the responsibility of making the request for recipes, and also knows how to fetch them.
  3. It has the responsibility of rendering the component.

As we can see at a glance, it seems like a normal component, like the ones we work with every day. However, in reality, if we base ourselves on SRP, the component has many responsibilities that it shouldn’t have.

So, how can we improve this?

Generally, if we have a useEffect inside a component, we can create a custom hook to perform that action and avoid the useEffect within the component.

What we are going to do is create a custom hook in a new file that is responsible for the logic of obtaining recipe data as well as handling the state.

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


const useFetchRecipes = () => {
   const [recipes, setRecipes] = useState([]);
   const [isLoading, setIsLoading] = useState(true);
  
   useEffect(() => {
       axios.get("https://api.spoonacular.com/recipes/").then((res) => {
           setRecipes(res.data)
       }).catch(e => {
           errorToast(e.message);
       }).finally(() => {
           setIsLoading(false);
       })
   }, [])
  
   return { recipes, isLoading };
}

				
			

By removing the responsibilities of managing the state and fetching the recipes, our original component becomes much simpler and easier to understand. Now it only has the responsibility of rendering the information, which makes it easier to maintain and extend.

Our original component would look like this:

				
					import { useFetchRecipes } from "hooks/useFetchRecipes";


const Recipes = () => {
 const { recipes, isLoading } = useFetchRecipes();


 if (isLoading){
   return <div>Loading...</div>
 }
 return (
   <ul>
       {recipes.map(recipe => {
           return (
               <li>
                  <div>
                       <img src={recipe.img} />
                       <h3>{recipe.name}</h3>
                       <span>{recipe.description}</span>
                  </div>
               </li>
           )
       })}
   </ul>
 );
};


export default Recipes;
				
			

But if we examine our new hook in detail, we notice that it has several responsibilities. On one hand, it is responsible for managing the state, and on the other hand, it handles the data fetching. Therefore, it would also not comply with SRP.

What we could do is abstract the logic of fetching the recipes. In this case, we will create a new file called api.js, where we will put the following code:

				
					import axios from "axios";
import errorToast from "./errorToast";


const fetchRecipes = () => {
 return axios
   .get("https://api.spoonacular.com/recipes/")
   .catch((e) => {
     errorToast(e.message);
   })
   .then((res) => res.data);
};
				
			

And our custom hook would look like this:

				
					import { useEffect, useState } from "react";
import { fetchRecipes } from "./api";


const useFetchRecipes = () => {
 const [recipes, setRecipes] = useState([]);
 const [isLoading, setIsLoading] = useState(true);


 useEffect(() => {
   fetchRecipes()
     .then((recipes) => setRecipes(recipes))
     .finally(() => setIsLoading(false));
 }, []);


 return { recipes, isLoading };
};
				
			

It is important to highlight that, by applying the Single Responsibility Principle (SRP), we aim to achieve greater cohesion in the code and reduce the possibility of errors. However, this is not always easy to achieve and can result in an increase in the complexity of the file structure and more time spent planning and organizing the code.

In this particular example, we have applied SRP to each of the files we created, which has led to a more complex file structure but complies with the principle. However, it is important to consider that applying SRP is not always necessary or beneficial, and  in some cases, it may be preferable to accept some complexity in the code to avoid excessive abstraction. 

Ultimately, it is important to find a balance between effectively applying design principles and maintaining readable and maintainable code, without falling into the trap of over-engineering, which can lead to excessive abstraction and unnecessary complexity.

Some examples of when it is preferable not to apply SRP are when we work with: 

  • Form components: Form components may have multiple responsibilities, such as data validation, state management, and data updating. Splitting these responsibilities into separate components can be difficult and create a complex and difficult-to-maintain component structure. This  becomes especially complex based on my experience, and even more so when you use some third party library and want to abstract logic to reuse it.
  • Table components: Table components may have multiple responsibilities, such as data representation and user interaction management. Splitting these responsibilities into separate components can be difficult and create a complex logic.

Open/closed principle 

This principle states that “software entities should be open for extension, but closed for modification” [2]. Since our React components and functions are software entities, we do not need to modify their definition.

Open for extension means that we can give a module another behavior or change what a certain module does.

Closed for modification means that to add a new behavior, we do not have to change the code of our existing classes or working modules.

A quick and simple example that violates this principle is code that is determined by RTTI (RunTime Type Information).

Continuing with the current context, there are multiple types of recipes, each possessing distinct characteristics. These recipes are represented in the user interface through a card, and the information displayed on each of them varies depending on the type. Specifically, there are two types of recipes, those we’ve created and those sourced from an external site. This is the reason why the example I am going to present is based on the aforementioned scenario.

				
					const RecipeCard = ({id, name, ingredients, type, onClick}) => {
   return (
       <Card>
           <CardHeader>
               <CardTitle>{name}</CardTitle>
           </CardHeader>
           <CardBody>
               <p>Id: {id}</p>
               <IngredientsFields>
                   {ingredients.map((ingredient) => {
                       <Ingredient>{ingredient}</Ingredient>
                   })}
               </IngredientsFields>
               {type === 'byAuthor' &&
                   <button onClick={onClick}>Go to site</button>
               }
               {type === 'byUs' &&
                   <button onClick={onClick}>Go to cook steps</button>
               }
           </CardBody>
       </Card>
   )    
}
				
			

In the component RecipeCard, it can be observed that there is a check for the Recipe type in order to render certain features. 

This example shows a clear violation of the Open/Closed principle, as the addition of a new Recipe type requires manual modification of the component to add a new condition. This implies altering the existing code, which contradicts the mentioned principle.

To solve this problem, a modification at the component level is proposed. First, the RecipeCard component will be updated:

				
					const RecipeCard = ({id, name, ingredients}) => {
   return (
       <Card>
           <CardHeader>
               <CardTitle>{name}</CardTitle>
           </CardHeader>
           <CardBody>
               <p>Id: {id}</p>
               <IngredientsFields>
                   {ingredients.map((ingredient) => {
                       <Ingredient>{ingredient}</Ingredient>
                   })}
               </IngredientsFields>
               {children}
           </CardBody>
       </Card>
   )    
}
				
			

As you can see, the number of props passed to the RecipeCard component has been reduced, retaining only those that are common to all types, such as id, name, and ingredients.

To render the different types of Recipes in a specific way, it is proposed to create specific components for each. These components will be created based on the previously mentioned base component and will be responsible for rendering the information corresponding to each type. In this sense, the creation of two new specific components is proposed:

				
					const AuthorRecipeCard = ({id, name, ingredients, onClick}) => {
   return (
       <RecipeCard id={id} name={name} ingredients={ingredients}>
           <button onClick={onClick}>Go to site</button>
       </RecipeCard>
   )    
}



const OwnRecipeCard = ({id, name, ingredients, onClick}) => {
   return (
       <RecipeCard id={id} name={name} ingredients={ingredients}>
           <button onClick={onClick}>Go to cook steps</button>
       </RecipeCard>
   )    
}
				
			

With this new implementation, the RecipeCard component has become much more extensible thanks to the use of the Children prop. Additionally, different components have been created for the different types of Recipe, which use the Children prop to render the specific information that corresponds to them. Therefore, if a new type of Recipe is added later, it would simply be a matter of adding a new component and passing the different information through the Children prop.

This approach is much more efficient since we now have a component that doesn’t rely on many props to render different things; we simply need to render the corresponding component with the necessary properties. Furthermore, if a component has many internal behaviors, it could be violating the Single Responsibility Principle (SRP), so this new implementation contributes to maintaining a cleaner and more coherent code structure.

Liskov substitution principle

The Liskov Substitution Principle (LSP) is a principle that states, “if a class A is a subtype of class B, then objects of type B can be replaced with objects of type A without causing errors in the program” [3].

The subtype/supertype relationship was originally established through class inheritance, but it can extend beyond that. In a broader context, inheritance refers to building one object on top of another object while maintaining a comparable implementation. 

A simple example of violating this principle is when we have a RecipeCard component that receives children, a color, and a size as props, and depending on the size prop, the font size is defined.

				
					const RecipeCard = ({children, color, size}) => {
   return (
       <Card style={{color, fontSize: size === 'xl' ? '32px' : '16px'}}>
           {children}
       </Card>
   )
}
				
			

Later, we come across a subcomponent that consists of a dark mode RecipeCard. This dark RecipeCard uses the RecipeCard component with a predefined color and also receives the “isBig” property as a prop. However, we cannot directly pass the “isBig” property to the RecipeCard component because it is a boolean value, whereas the RecipeCard component expects a string value as input.

				
					const DarkRecipeCard = ({children, isBig}) => {
   return (
       <RecipeCard color='dark' size={isBig ? 'xl' : 'tb'}>
           {children}
       </RecipeCard>
   )
}
				
			

If we render the DarkRecipeCard within a component, we will see that it works properly. However, we would not be complying with the Liskov Substitution Principle. Why? Well, if we want to replace the DarkRecipeCard with the RecipeCard, we cannot do so. This is because, although DarkRecipeCard is composed of the RecipeCard component, we have modified the behavior of the props that are passed down, changing their names.

Although there is no inheritance per se, we can consider DarkRecipeCard as an object that constitutes a subtype of the RecipeCard type, since we are generating it from the RecipeCard type. To solve this “problem,” the properties received by DarkRecipeCard must be compatible with those of the “parent” type (RecipeCard).

				
					const DarkRecipeCard = ({children, size}) => {
   return (
       <RecipeCard color='dark' size={size}>
           {children}
       </RecipeCard>
   )
}
				
			

This solves our problem, allowing us to use both the subtype and the type (there would only be UI changes, as the subtype makes sense to be more specific than the type, in this case the fixed color).

Another very crude example based on class components is the following:

Let’s assume we have a base Button component class that has a render() method that returns a button with a given text. If we create a derived CancelButton component class that extends Button, then the render() method in CancelButton should render a cancel button instead of a normal button.

However, if CancelButton changes the behavior of the render() method in such a way that it doesn’t look like a button anymore, then we would be violating the Liskov Substitution Principle:

				
					class Button extends React.Component {
   render() {
     return (
       <button>{this.props.text}</button>
     );
   }
 }
 
class CancelButton extends Button {
   render() {
     return (
       <div>{this.props.text}</div>
     );
   }
 }
				
			

In this example, the CancelButton component receives the same text prop as the Button component, but returns a div instead of a button. While this may be desirable in certain cases, it violates the Liskov Substitution Principle, as an instance of CancelButton cannot be used in place of an instance of Button without changing the behavior of the program.

To solve this problem, we can ensure that the CancelButton component still returns a button, but with a different style or icon to indicate that it is a cancel button.

				
					class CancelButton extends Button {
   render() {
     return (
       <button style={{ backgroundColor: 'red' }}>{this.props.text}</button>
       );
   }
 }
				
			

In this example, the CancelButton component still returns a button, but with a different style to indicate that it’s a cancel button. This way, any instance of CancelButton can still be used in place of an instance of Button without changing the program’s behavior and complying with the Liskov Substitution Principle.

Interface segregation principle

According to the ISP, “clients should not depend upon interfaces that they don’t use” [4]. In the case of React applications, we will translate it as “components should not depend on props they don’t use.”

We are broadening the definition of the ISP slightly, but the distinction isn’t significant. Both props and interfaces serve as agreements between the object (component) and its external environment (the context where it is utilized), allowing us to draw similarities between them. 

Let’s imagine we have a component called “RecipeCard,” whose function is to show relevant information about a recipe in a concise way. This component receives a prop called “recipeData,” which is an object containing all the information related to the recipe, including its name, type, author, ingredients, photo, and cook steps (steps to follow, temperature to cook, how long it takes, etc).

However, for the specific purpose of this component, we only require certain specific data from the “recipeData” object, such as the name, type, ingredients, and the photo. Supplying the entire “recipeData” object with unnecessary information violates the ISP principle, as we are forcing clients of our component to depend on an interface they are not using. In fact, the “RecipeCard” component should only receive the data it needs to function properly. Because if in the PropTypes, we had defined the recipeData object as shape, in case that any property that is not used, changes, to avoid that the application breaks down, we must come to the component and change the propType of that property.

				
					const RecipeCard = ({ recipeData }) => {
   return (
     <Card>
       <Title>{recipeData.name}</Title>
       <TypeLabel>{recipeData.type}</TypeLabel>
       {recipeData.ingredients.map((ingredient) =>
       (<Ingredient>{ingredient}</Ingredient>))}
       <Photo src={recipeData.img} />
     </Card>
   );
}
RecipeCard.propTypes = {
   recipeData: PropTypes.object.isRequired,
);
				
			

To solve this, we can refactor the RecipeCard component to receive only the necessary information.

				
					const RecipeCard = ({ name, type, ingredients, photo }) => {
   return (
     <Card>
       <Title>{name}</Title>
       <TypeLabel>{type}</TypeLabel>
       {ingredients.map((ingredient) =>
       (<Ingredient>{ingredient}</Ingredient>))}
       <Photo src={photo} />
     </Card>
   );
}
RecipeCard.propTypes = {
   name: PropTypes.string.isRequired,
   type: PropTypes.string.isRequired,
   ingredients: PropTypes.array.isRequired,
   photo: PropTypes.string.isRequired,
};
				
			

By doing so, we are rigorously applying the ISP principle, thus avoiding clients of our component from depending on an interface they are not using. This minimizes the dependency between components and simplifies the contract between components in terms of the props that are passed from one component to another. By adhering to this principle, we are ensuring greater cohesion and less coupling between the different components of our application, which results in easier maintenance, scalability, and extensibility of the code.

Dependency inversion principle

This principle refers to the Dependency Inversion Principle (DIP), which states that “high-level modules should not depend directly on low-level modules, but both should depend on common abstractions. Similarly, abstractions should not depend on implementation details, but details should depend on abstractions” [5]. This practice allows for greater flexibility in design and avoids the propagation of changes to higher levels, which can be unacceptable.

In the context of React, this principle applies to components, which should not directly depend on other components, but both should depend on a common abstraction. In this case, “component” refers to any part of the application, whether it’s a React component, a function, a module, or a third-party library.

For this example, let’s return to the recipe example we used in the SRP principle, with a slight modification. In this case, we have the component that displays the list of available recipes. Initially, it shows a button that is used to fetch the recipes from an APIwhen clicked.

				
					import React, { useState } from "react";
import api from './api'


const Recipes = () => {
const [recipes, setRecipes] = useState([]);


const handleFetch = async () => {
   const fetchedRecipes = await api.fetchRecipes();
   setRecipes(fetchedRecipes);
 }


if (isEmpty(recipes)){
   return <button onClick={handleFetch}>Fetch recipes</button>
}


return (
  <ul>
      {recipes.map(recipe => {
          return (
              <li>
                 <div>
                      <img src={recipe.img} />
                      <h3>{recipe.name}</h3>
                      <span>{recipe.description}</span>
                 </div>
              </li>
          )
      })}
  </ul>
);
};
export default Recipes;
				
			

And then we have the api.js file, which has the method to fetch the recipes.

				
					import axios from "axios";


const fetchRecipes = () => {
   axios.get("https://api.spoonacular.com/recipes/").then((res) => {
      return res.data
  }).catch(e => {
      errorToast(e.message);
  })
}
				
			

In the Recipes component, a direct dependency on the API module can be observed, which generates tight coupling between them. This coupling represents a bad practice since any changes in one component can affect other components, making it harder to modify the code.

In this sense, the principle of inversion of dependency advocates for the elimination of this coupling, seeking ways to break this direct dependency. To achieve this goal, one possible solution is to remove the direct reference to the API module from the Recipes component and instead allow the required functionality to be injected through props.

This practice allows the Recipes component to depend on a common abstraction, instead of directly depending on the API module, which facilitates flexibility and code maintenance. Additionally, if a different data source needs to be used, only the implementation of the common abstraction needs to be changed, rather than changing the component itself.

				
					const Recipes = ({ fetchRecipes }) => {
const [recipes, setRecipes] = useState([]);


const handleFetch = async () => {
   const fetchedRecipes = await fetchRecipes();
   setRecipes(fetchedRecipes);
 }


if (isEmpty(recipes)){
   return <button onClick={handleFetch}>Fetch recipes</button>
}


return (
  <ul>
      {recipes.map(recipe => {
          return (
              <li>
                 <div>
                      <img src={recipe.img} />
                      <h3>{recipe.name}</h3>
                      <span>{recipe.description}</span>
                 </div>
              </li>
          )
      })}
  </ul>
);
};
				
			

After making the proposed change, it can be observed that the Recipes component no longer depends on the API module. Instead, the logic to fetch recipes is abstracted through the fetchRecipes callback, and it is now the responsibility of the parent component to provide the concrete implementation of this logic.

In this way, the responsibilities of the Recipes component are separated and its direct dependency on the API is avoided, resulting in a more flexible and maintainable coupling. Additionally, this practice also facilitates component reuse, as it is now possible to use the same Recipes component with different data sources.

To implement this abstraction, one could create a component that connects the Recipes component with the API, allowing the logic to fetch recipes to be handled by this intermediate component. This way, the Recipes component is limited to presenting the fetched data and does not have to worry about how it is obtained. It is worth noting that the creation of this component is entirely optional and solely serves as an illustration of how to utilize the RecipeCard component with diverse sources.

				
					import api from './api'
import Recipes from './Recipes'


const ConnectedRecipes = () => {
 const handleFetchRecipes = async () => {
   return await api.fetchRecipes();
 }
 return (
   <Recipes fetchRecipes={handleFetchRecipes} />
 )
}
export default ConnectedRecipes;
				
			

The ConnectedRecipes component acts as an intermediary between the API and the Recipes component, allowing both to be completely independent of each other. This abstraction avoids the direct dependency between the components and reduces coupling in the application.

Thanks to this abstraction, we can iterate and test the components in isolation without worrying about breaking dependent moving pieces, because there are none. As long as both components adhere to the agreed-upon common abstraction, the application as a whole will continue to function as expected.

In summary, the main goal is to minimize coupling between different components of the application, whether they are functions, functional components, modules, or third-party libraries. This is achieved by reducing the direct dependency between the components, as in the case of the Recipes component and the API, resulting in a more flexible, scalable, and easy-to-maintain design.

The SOLID principles are a valuable guide to writing quality, maintainable code

As I explained in the post, if we apply these React SOLID Principles, it will allow us to create more flexible and reusable components that better adapt to the changing needs that may exist in a project. I hope it has helped you  learn a little more and that it will be useful.

References

[1] Martin, R. C. (2003). Agile software development: principles, patterns, and practices. Prentice Hall PTR. [Chapter 8. SRP: The Single-Responsibility Principle]

[2] Martin, R. C. (2003). Agile software development: principles, patterns, and practices. Prentice Hall PTR. [Chapter 9. OCP: The Open-Closed Principle]

[3] Martin, R. C. (2003). Agile software development: principles, patterns, and practices. Prentice Hall PTR. [Chapter 10. LSP: The Liskov Substitution Principle]

[4] Martin, R. C. (2003). Agile software development: principles, patterns, and practices. Prentice Hall PTR. [Chapter 11. DIP: The Dependency-Inversion Principle]

[5] Martin, R. C. (2003). Agile software development: principles, patterns, and practices. Prentice Hall PTR. [Chapter 12. ISP: The Interface-Segregation Principle]

See related posts