In this blog, I will demonstrate the implementation of SOLID principles in a React application. By the end of this article, you will fully grasp SOLID principles. Before we begin, let me give you a brief introduction to those principles.
SOLID principles are five design principles that help us keep our application reusable, maintainable, scalable, and loosely coupled. The SOLID principles are:
Okay, let’s examine each of these principles individually. I use React as an example, but the core concepts are similar to other programming languages and frameworks.
“A module should be responsible to one, and only one, actor.” — Wikipedia.
The Single Responsibility Principle states that a component should have one clear purpose or responsibility. It should focus on specific functionality or behavior and avoid taking on unrelated tasks. Following SRP makes components more focused, modular, easily comprehensible, and modified. Let’s see the actual implementation.
// ❌ Bad Practice: Component with Multiple Responsibilities
const Products = () => {
return (
<div className="products">
{products.map((product) => (
<div key={product?.id} className="product">
<h3>{product?.name}</h3>
<p>${product?.price}</p>
</div>
))}
</div>
);
};
In the above example, the Products
component violates the Single Responsibility Principle by taking on multiple responsibilities. It manages the iteration of products and handles the UI rendering for each product. This can make the component challenging to understand, maintain, and test in the future.
// ✅ Good Practice: Separating Responsibilities into Smaller Components
import Product from './Product';
import products from '../../data/products.json';
const Products = () => {
return (
<div className="products">
{products.map((product) => (
<Product key={product?.id} product={product} />
))}
</div>
);
};
// Product.js
// Separate component responsible for rendering the product details
const Product = ({ product }) => {
return (
<div className="product">
<h3>{product?.name}</h3>
<p>${product?.price}</p>
</div>
);
};
This separation ensures each component has a single responsibility, making them easier to understand, test, and maintain.
“software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.” — Wikipedia.
The Open-Closed Principle emphasizes that components should be open for extension (can add new behaviors or functionalities) but closed for modification(existing code should remain unchanged). This principle encourages the creation of code that is resilient to change, modular, and easily maintainable. Let’s see the actual implementation.
// ❌ Bad Practice: Violating the Open-Closed Principle
// Button.js
// Existing Button component
const Button = ({ text, onClick }) => {
return (
<button onClick={onClick}>
{text}
</button>
);
}
// Button.js
// Modified Existing Button component with additional icon prop (modification)
const Button = ({ text, onClick, icon }) => {
return (
<button onClick={onClick}>
<i className={icon} />
<span>{text}</span>
</button>
);
}
// Home.js
// 👇 Avoid: Modified existing component prop
const Home = () => {
const handleClick= () => {};
return (
<div>
{/* ❌ Avoid this */}
<Button text="Submit" onClick={handleClick} icon="fas fa-arrow-right" />
</div>
);
}
In the above example, we modify the existing Button
component by adding an icon
prop. Altering an existing component to accommodate new requirements violates the Open-Closed Principle. These changes make the component more fragile and introduce the risk of unintended side effects when used in different contexts.
// ✅ Good Practice: Open-Closed Principle
// Button.js
// Existing Button functional component
const Button = ({ text, onClick }) => {
return (
<button onClick={onClick}>
{text}
</button>
);
}
// IconButton.js
// IconButton component
// ✅ Good: You have not modified anything here.
const IconButton = ({ text, icon, onClick }) => {
return (
<button onClick={onClick}>
<i className={icon} />
<span>{text}</span>
</button>
);
}
const Home = () => {
const handleClick = () => {
// Handle button click event
}
return (
<div>
<Button text="Submit" onClick={handleClick} />
{/*
<IconButton text="Submit" icon="fas fa-heart" onClick={handleClick} />
</div>
);
}
In the above example, we create a separate IconButton
functional component. The IconButton
component encapsulates the rendering of an icon button without modifying the existing Button
component. It adheres to the Open-Closed Principle by extending the functionality through composition rather than modification.
“Subtype objects should be substitutable for supertype objects” — Wikipedia.
The Liskov Substitution Principle (LSP) is a fundamental principle of object-oriented programming that emphasizes the need for substitutability of objects within a hierarchy. In the context of React components, LSP promotes the idea that derived components should be able to substitute their base components without affecting the correctness or behavior of the application. Let’s see the actual implementation.
// ⚠️ Bad Practice
// This approach violates the Liskov Substitution Principle as it modifies
// the behavior of the derived component, potentially resulting in unforeseen
// problems when substituting it for the base Select component.
const BadCustomSelect = ({ value, iconClassName, handleChange }) => {
return (
<div>
<i className={iconClassName}></i>
<select value={value} onChange={handleChange}>
<options value={1}>One</options>
<options value={2}>Two</options>
<options value={3}>Three</options>
</select>
</div>
);
};
const LiskovSubstitutionPrinciple = () => {
const [value, setValue] = useState(1);
const handleChange = (event) => {
setValue(event.target.value);
};
return (
<div>
{/** ❌ Avoid this */}
{/** Below Custom Select doesn't have the characteristics of base `select` element */}
<BadCustomSelect value={value} handleChange={handleChange} />
</div>
);
In the above example, we have a BadCustomSelect
component intended to serve as a custom select input in React. However, it violates the Liskov Substitution Principle (LSP) because it restricts the behavior of the base select
element.
// ✅ Good Practice
// This component follows the Liskov Substitution Principle and allows the use of select's characteristics.
const CustomSelect = ({ value, iconClassName, handleChange, ...props }) => {
return (
<div>
<i className={iconClassName}></i>
<select value={value} onChange={handleChange} {...props}>
<options value={1}>One</options>
<options value={2}>Two</options>
<options value={3}>Three</options>
</select>
</div>
);
};
const LiskovSubstitutionPrinciple = () => {
const [value, setValue] = useState(1);
const handleChange = (event) => {
setValue(event.target.value);
};
return (
<div>
{/* ✅ This CustomSelect component follows the Liskov Substitution Principle */}
<CustomSelect
value={value}
handleChange={handleChange}
defaultValue={1}
/>
</div>
);
};
In the revised code, we have a CustomSelect
component intended to extend the functionality of the standard select
element in React. The component accepts props such as value
, iconClassName
, handleChange
, and additional props using the spread operator ...props
. By allowing the use of the select
element's characteristics and accepting additional props, the CustomSelect
component follows the Liskov Substitution Principle (LSP).
“No code should be forced to depend on methods it does not use.” — Wikipedia.
The Interface Segregation Principle (ISP) suggests that interfaces should be focused and tailored to specific client requirements rather than being overly broad and forcing clients to implement unnecessary functionality. Let’s see the actual implementation.
// ❌ Avoid: disclose unnecessary information for this component
// This introduces unnecessary dependencies and complexity for the component
const ProductThumbnailURL = ({ product }) => {
return (
<div>
<img src={product.imageURL} alt={product.name} />
</div>
);
};
// ❌ Bad Practice
const Product = ({ product }) => {
return (
<div>
<ProductThumbnailURL product={product} />
<h4>{product?.name}</h4>
<p>{product?.description}</p>
<p>{product?.price}</p>
</div>
);
};
const Products = () => {
return (
<div>
{products.map((product) => (
<Product key={product.id} product={product} />
))}
</div>
);
}
In the above example, we pass the entire product details to the ProductThumbnailURL
component, even though it doesn’t require it. It adds unnecessary risks and complexity to the component and violates the Interface Segregation Principle (ISP).
// ✅ Good: reducing unnecessary dependencies and making
// the codebase more maintainable and scalable.
const ProductThumbnailURL = ({ imageURL, alt }) => {
return (
<div>
<img src={imageURL} alt={alt} />
</div>
);
};
// ✅ Good Practice
const Product = ({ product }) => {
return (
<div>
<ProductThumbnailURL imageURL={product.imageURL} alt={product.name} />
<h4>{product?.name}</h4>
<p>{product?.description}</p>
<p>{product?.price}</p>
</div>
);
};
const Products = () => {
return (
<div>
{products.map((product) => (
<Product key={product.id} product={product} />
))}
</div>
);
};
In the revised code, the ProductThumbnailURL
component only receives the required information instead of the entire product details. It prevents unnecessary risks and fosters the Interface Segregation Principle (ISP).
“One entity should depend upon abstractions, not concretions” — Wikipedia.
The Dependency Inversion Principle (DIP) emphasizes that high-level components should not depend on low-level components. This principle fosters loose coupling and modularity and facilitates easier maintenance of software systems. Let’s see the actual implementation.
// ❌ Bad Practice
// This component follows concretion instead of abstraction and
// breaks Dependency Inversion Principle
const CustomForm = ({ children }) => {
const handleSubmit = () => {
// submit operations
};
return <form onSubmit={handleSubmit}>{children}</form>;
};
const DependencyInversionPrinciple = () => {
const [email, setEmail] = useState();
const handleChange = (event) => {
setEmail(event.target.value);
};
const handleFormSubmit = (event) => {
// submit business logic here
};
return (
<div>
{/** ❌ Avoid: tightly coupled and hard to change */}
<BadCustomForm>
<input
type="email"
value={email}
onChange={handleChange}
name="email"
/>
</BadCustomForm>
</div>
);
};
The CustomForm
component is tightly coupled to its children, preventing flexibility and making it challenging to change or extend its behavior.
// ✅ Good Practice
// This component follows abstraction and promotes Dependency Inversion Principle
const AbstractForm = ({ children, onSubmit }) => {
const handleSubmit = (event) => {
event.preventDefault();
onSubmit();
};
return <form onSubmit={handleSubmit}>{children}</form>;
};
const DependencyInversionPrinciple = () => {
const [email, setEmail] = useState();
const handleChange = (event) => {
setEmail(event.target.value);
};
const handleFormSubmit = () => {
// submit business logic here
};
return (
<div>
{/** ✅ Use the abstraction instead */}
<AbstractForm onSubmit={handleFormSubmit}>
<input
type="email"
value={email}
onChange={handleChange}
name="email"
/>
<button type="submit">Submit</button>
</AbstractForm>
</div>
);
};
In the revised code, we introduce the AbstractForm
component, which acts as an abstraction for the form. It receives the onSubmit
function as a prop and handles the form submission. This approach allows us to easily swap out or extend the form behavior without modifying the higher-level component.
The SOLID principles provide guidelines that empower developers to create well-designed, maintainable, and extensible software solutions. By adhering to these principles, developers can achieve modularity, code reusability, flexibility, and reduced code complexity.
I hope this blog has provided valuable insights and inspired you to apply these principles in your existing or following React projects.
Stay curious; keep coding!
Reference:
Also published here.