React with SOLID: the main concepts

When React apps get bigger, they often get messy. Unreadable components, tangled logic, and tightly coupled features slow down development. The SOLID principles, originally from OOP, help break this cycle — even in React.
In this post, we’ll walk through the SOLID principles in the context of React, showing both a “bad” implementation (without SOLID) and a “refactored” version (with SOLID). You’ll see exactly what changes and why it matters.
What is SOLID?
SOLID is an acronym for five design principles that make software easier to maintain and scale:
- S – Single Responsibility Principle (SRP)
- O – Open/Closed Principle (OCP)
- L – Liskov Substitution Principle (LSP)
- I – Interface Segregation Principle (ISP)
- D – Dependency Inversion Principle (DIP)
Let’s see what these mean for React in practice.
The Example Problem: A UserProfile Component
Suppose we’re building a UserProfile component that:
- Fetches user data
- Displays a loading spinner
- Shows user information
- Handles errors
Without SOLID
Here’s a tightly coupled component that violates every SOLID principle:
// UserProfile.js (Without SOLID)
import React, { useEffect, useState } from "react";
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`https://api.example.com/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [userId]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
};
export default UserProfile;
Problems:
- SRP: It handles fetching, loading states, rendering, and error handling all in one.
- OCP: You can't change fetching logic or UI independently.
- LSP: You can’t substitute this with another component without breaking behavior.
- ISP: Consumers can’t choose which parts of the component they need.
- DIP: It’s tightly coupled to the fetch API and its URL.
With SOLID
Let’s break it up and apply SOLID principles:
1. Single Responsibility Principle
Split responsibilities: data fetching, state handling, and UI are separate.
// useUser.js
import { useEffect, useState } from "react";
export const useUser = (userId, fetchUser) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchUser(userId)
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [userId, fetchUser]);
return { user, loading, error };
};
2. Open/Closed Principle
Use props to pass in render logic (open for extension, closed for modification):
// UserProfile.js
import React from "react";
import { useUser } from "./useUser";
const UserProfile = ({
userId,
fetchUser,
renderLoading,
renderError,
renderUser,
}) => {
const { user, loading, error } = useUser(userId, fetchUser);
if (loading) return renderLoading();
if (error) return renderError(error);
return renderUser(user);
};
export default UserProfile;
3. Liskov Substitution Principle
This component can be safely replaced by any other component that uses the same prop contract. It won’t break the app.
4. Interface Segregation Principle
Consumers of UserProfile choose what parts they need: error view, user view, loading view.
// App.js
import UserProfile from "./UserProfile";
import { fetchUserById } from "./userService";
const App = () => (
<UserProfile
userId="123"
fetchUser={fetchUserById}
renderLoading={() => <p>Loading user...</p>}
renderError={err => <p>Oops: {err}</p>}
renderUser={user => (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)}
/>
);
5. Dependency Inversion Principle
Inject dependencies (fetchUserById) instead of hardcoding them. This makes testing and mocking easy.
// userService.js
export const fetchUserById = id => {
return fetch(`https://api.example.com/users/${id}`).then(res => res.json());
};
Side-by-Side: SOLID vs Non-SOLID
| Principle | Without SOLID | With SOLID |
|---|---|---|
| SRP | One component does everything | Split into useUser, UI, services |
| OCP | Hard to change logic/UI independently | Easily extend with new render logic |
| LSP | Hard to substitute | Fully swappable |
| ISP | Consumers can't pick what they want | Full control via render props |
| DIP | Tightly coupled to fetch | Decoupled via dependency injection |
Why It Matters
React doesn’t force structure — that’s a blessing and a curse. SOLID principles give you a mental model to build maintainable, testable, and scalable components.
You won’t need to refactor everything upfront, but next time you're writing a component, think:
- Can I split concerns?
- Can I inject dependencies?
- Can I let the parent choose behavior?
If yes, you’re on the right path.
Final Thoughts
SOLID isn't just for enterprise Java. It’s a practical toolset for any React dev who wants cleaner code and fewer headaches. Start small. Refactor smart. And make your components solid — literally.
