If you’ve been working with React for a while, you’ve probably heard about best practices like keeping components small, making them reusable, and separating concerns. But have you ever considered applying SOLID principles to your React projects?
Originally introduced by Robert C. Martin (aka Uncle Bob) for object-oriented programming, SOLID principles help developers write scalable, maintainable, and robust software. While JavaScript’s dynamic nature doesn’t always align with traditional OOP paradigms, React’s component-based architecture makes it an excellent candidate for SOLID principles.
In this article, we’ll break down each of the five SOLID principles—Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion—and explore how they naturally fit into React development. By the end, you’ll have practical strategies to write cleaner, more modular React components that scale effectively.
S – Single Responsibility Principle
The Single Responsibility Principle states that a class (or function) should have one and only one reason to change. In the context of React, this means each component should have a single responsibility.
React’s component-based architecture naturally encourages SRP by breaking down UI elements into small, focused components. Keeping components granular improves reusability, readability, and testability.
✅ Good Example: A well-structured dashboard page. Here, FinishedBooksGraph, NewBook, and NewReview are separate components handling their respective responsibilities, instead of one bloated Dashboard component trying to do everything.
function Dashboard() {
return (
<div className="Dashboard">
<FinishedBooksGraph />
<NewBook />
<NewReview />
</div>
);
}
❌ Bad Example: A component that does too much. This component violates SRP by handling both file uploading and displaying a file list. Instead, it should be broken into FileUpload and FileList components.
function Dashboard() {
function uploadFile() {...}
return (
<div>
<h1>Your Saved Files</h1>
<button onClick={uploadFile}>Upload File</button>
<ul>
{files.map(file => (
<li key={file.id}>{file.name}</li>
))}
</ul>
</div>
);
}
O – Open-Closed Principle
The Open-Closed Principle states that software entities (components, modules, functions) should be open for extension but closed for modification.
In React, this means creating flexible, reusable components that can be extended without modifying their core logic. This is achieved using props and higher-order components (HOCs) or hooks.
✅ Good Example: A reusable Button component. Here, the Button component remains unchanged while allowing different variations through props.
const Button = ({ text, style, onClick }) => {
return (
<button style={style} onClick={onClick}>
{text}
</button>
);
};
// Reusing the Button component with different styles and behaviors
const CancelButton = () => (
<Button text="Cancel" style={{ backgroundColor: 'red' }} onClick={cancelFunction} />
);
const SubmitButton = () => (
<Button text="Submit" style={{ backgroundColor: 'green' }} onClick={submitFunction} />
);
❌ Bad Example: Hardcoded button logic. This design violates OCP because modifying the button requires editing the original component instead of extending it.
const Button = ({ type }) => {
if (type === "cancel") {
return <button style={{ backgroundColor: 'red' }}>Cancel</button>;
} else if (type === "submit") {
return <button style={{ backgroundColor: 'green' }}>Submit</button>;
}
};
L – Liskov Substitution Principle
The Liskov Substitution Principle states that subtypes must be replaceable by their parent type without breaking functionality.
In React, this principle applies when designing components with inheritance or composition. If a derived component cannot be seamlessly swapped in place of its parent component, LSP is violated.
✅ Good Example: Swappable button components – Both CancelButton and CTAButton can replace Button without breaking functionality, ensuring LSP compliance.
const Button = ({ text, style, onClick }) => {
return (
<button style={style} onClick={onClick}>
{text}
</button>
);
};
const CancelButton = () => (
<Button text="Cancel" style={{ backgroundColor: 'red' }} onClick={cancelFunction} />
);
const CTAButton = () => (
<Button text="Let’s Go!" style={{ backgroundColor: 'green' }} onClick={ctaFunction} />
);
❌ Bad Example: A subclass that doesn’t follow the parent’s contract
const Button = ({ text, style, onClick }) => {
return (
<button style={style} onClick={onClick}>
{text}
</button>
);
};
// This violates LSP because it breaks the expected contract of a button
const LinkButton = ({ href, text }) => {
return <a href={href}>{text}</a>; // No onClick function
};
🔴 Why this breaks LSP:
-
LinkButton does not support onClick, meaning it cannot replace Button in all cases.
-
If another part of the code expects an onClick function, LinkButton will break the program when used as a Button replacement.
-
The correct approach would be to extend Button instead of replacing it:
const LinkButton = ({ href, text }) => {
return <Button text={text} onClick={() => window.location.href = href} />;
};
Now, LinkButton maintains the expected behavior of a button, satisfying LSP.
I – Interface Segregation Principle
The Interface Segregation Principle states that components should only depend on the data they need—avoiding passing unnecessary props or forcing components to implement unused functionality.
✅ Good Example: Passing only necessary props. Here, DashboardHeader only receives the name, instead of an entire user object with unnecessary properties.
const DashboardHeader = ({ name }) => {
return <div>Hello, {name}!</div>;
};
function Dashboard() {
return (
<div className="Dashboard">
<DashboardHeader name={user.name} />
</div>
);
}
❌ Bad Example: Passing properties through multiple functions (Prop Drilling)
function Dashboard() {
return (
<div className="Dashboard">
<WidgetContainer userName={user.name} userImage={user.image} />
</div>
);
}
const WidgetContainer = ({ userName, userImage }) => {
return (
<div>
<UserInfoWidget userName={userName} userImage={userImage} />
</div>
);
};
const UserInfoWidget = ({ userName, userImage }) => {
return (
<div>
<UserAvatar userName={userName} userImage={userImage} />
</div>
);
};
const UserAvatar = ({ userName, userImage }) => {
return (
<div>
<img src={userImage} alt={`${userName}'s avatar`} />
<div>{userName}</div>
</div>
);
};
🔴 Why this violates ISP:
- userName and userImage are unnecessarily passed through multiple components, even though only UserAvatar actually needs them.
- This forces WidgetContainer and UserInfoWidget to depend on props they don’t actually use, violating ISP.
- Better approach: Use React Context API or a state management tool instead of passing down unnecessary props.
✅ Fix with Context API:
const UserContext = React.createContext();
function Dashboard() {
return (
<UserContext.Provider value={{ name: user.name, image: user.image }}>
<WidgetContainer />
</UserContext.Provider>
);
}
const WidgetContainer = () => <UserInfoWidget />;
const UserInfoWidget = () => <UserAvatar />;
const UserAvatar = () => {
const { name, image } = React.useContext(UserContext);
return (
<div>
<img src={image} alt={`${name}'s avatar`} />
<div>{name}</div>
</div>
);
};
💡 Why this follows ISP:
- WidgetContainer and UserInfoWidget no longer depend on userName or userImage.
- UserAvatar directly gets the data it needs, avoiding unnecessary dependencies.
D – Dependency Inversion Principle
The Dependency Inversion Principle states that components should depend on abstractions, not concretions. This means avoiding direct dependencies on implementation details and instead using abstractions like hooks, context, or higher-order components.
✅ Good Example: Abstracting API calls with a separate service
// apiService.js (Abstraction Layer)
export const apiService = {
createProduct: async (formData) => {
try {
await axios.post("https://api/createProduct", formData);
} catch (err) {
console.error(err.message);
}
},
};
// Custom Hook (Abstraction for Components)
const useProducts = () => {
return { createProduct: apiService.createProduct };
};
// UI Component (Only Handles UI)
const ProductForm = ({ onSubmit }) => {
return (
<form onSubmit={onSubmit}>
<input name="name" />
<input name="description" />
<input name="price" />
</form>
);
};
// Component Using the Abstraction
const CreateProductForm = () => {
const { createProduct } = useProducts();
return <ProductForm onSubmit={createProduct} />;
};
By separating API logic into a custom hook (useProducts), we decouple components from direct API calls, making them more flexible and testable.
❌ Bad Example: Hardcoding API calls inside components
const CreateProductForm = () => {
const createProduct = async () => {
await axios.post("https://api/createProduct", formData);
};
return <form onSubmit={createProduct}>...</form>;
};
This violates DIP by directly coupling API logic to UI components, making it harder to modify or test. Applying SOLID principles to React projects results in cleaner, more maintainable, and scalable applications. While these principles were originally designed for OOP, React’s component-based nature aligns well with them. By focusing on small, reusable, and well-abstracted components, you can build applications that are easy to understand and extend over time.