In the world of Object-Oriented Programming (OOP), SOLID principles are five fundamental guidelines to design and organize code in a way that’s scalable, manageable, and easy to understand. However, these principles aren’t confined to OOP. They can be just as effective in the realm of React development.
In this post, we’ll discuss each of the SOLID principles and show you how to apply them to your React code.
Single Responsibility Principle (SRP)
The Single Responsibility Principle states that each class or module should have only one reason to change. In the context of React, we interpret this as: each component should handle one functionality.
Let’s consider a simple example, a UserProfile
component responsible for both fetching data and rendering it.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`https://api.example.com/users/${userId}`)
.then(response => response.json())
.then(data => setUser(data));
}, [userId]);
return (
user ?
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div> :
'Loading...'
);
}
In the above component, data fetching and UI rendering are mingled together, violating SRP. We can refactor this component into two: UserFetcher
and UserProfile
.
function UserFetcher({ userId, children }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`https://api.example.com/users/${userId}`)
.then(response => response.json())
.then(data => setUser(data));
}, [userId]);
return children(user);
}
function UserProfile({ user }) {
return user ? (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
) : (
'Loading...'
);
}
// Usage
<UserFetcher userId="1">
{user => <UserProfile user={user} />}
</UserFetcher>
In the refactored code, UserFetcher
is responsible for fetching user data, and UserProfile
is responsible for rendering it. Each component now adheres to the Single Responsibility Principle.
Open/Closed Principle (OCP)
The Open/Closed Principle states that software entities should be open for extension but closed for modification. In the React context, this means we should be able to add new features or behavior to components without modifying their existing source code.
Imagine we have a UserList
component that renders each user with a UserCard
component.
function UserCard({ user }) {
return (
<div>
<h2>{user.name}</h2>
<p>{user.description}</p>
</div>
);
}
function UserList({ users }) {
return (
<div>
{users.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}
Now, we want to introduce a UserCardPremium
, which should render additional data for premium users. Instead of modifying the UserCard
component, we can extend it to a new component UserCardPremium
.
function UserCardPremium({ user }) {
return (
<div>
<h2>{user.name} (Premium)</h2>
<p>{user.description}</p>
<p>Premium features: {user.premiumFeatures}</p>
</div>
);
}
We then modify the UserList
component to render UserCardPremium
for premium users, and UserCard
for others:
function UserList({ users }) {
return (
<div>
{users.map(user =>
user.isPremium ? (
<UserCardPremium key={user.id} user={user} />
) : (
<UserCard key={user.id} user={user} />
)
)}
</div>
);
}
In this way, we adhere to the Open/Closed Principle. We didn’t modify the UserCard
component; instead, we extended its behavior with a new component UserCardPremium
.
Liskov Substitution Principle (LSP)
The Liskov Substitution Principle states that subclasses must be substitutable for their superclass. In other words, a component should be replaceable by any of its sub-components without causing any issues.
For instance, if we have a List
component that takes an array of items
and a renderItem
function prop which defines how each item in the list should be rendered:
function List({ items, renderItem }) {
return (
<ul>
{items.map((item, index) => (
<li key={index}>{renderItem(item)}</li>
))}
</ul>
);
}
We use this List
component to render a list of user names:
function UserList({ users }) {
return (
<List
items={users}
renderItem={(user) => <span>{user.name}</span>}
/>
);
}
Now, we want to introduce a UserListWithLink
component, which should render the users’ names as links. According
to LSP, we can create a new renderItem
function and use it with the existing List
component without causing any issues:
function UserListWithLink({ users }) {
return (
<List
items={users}
renderItem={(user) => <a href={`/users/${user.id}`}>{user.name}</a>}
/>
);
}
This way, the List
component can be substituted with its “child” component UserListWithLink
without any problems, adhering to the Liskov Substitution Principle.
Interface Segregation Principle (ISP)
The Interface Segregation Principle states that no client should be forced to depend on interfaces it does not use. In the context of React, we can interpret it as: components should not have to receive more props than they need.
Let’s consider a component UserDetails
that renders user information:
function UserDetails({ user }) {
return (
<div>
<p>Name: {user.name}</p>
<p>Email: {user.email}</p>
<p>Username: {user.username}</p>
</div>
);
}
Now, imagine we have a new requirement to show the user’s address in a different component. It might be tempting to add the address to the UserDetails
component:
function UserDetails({ user }) {
return (
<div>
<p>Name: {user.name}</p>
<p>Email: {user.email}</p>
<p>Username: {user.username}</p>
<p>Address: {user.address}</p>
</div>
);
}
However, this violates the Interface Segregation Principle. The UserDetails
component now depends on more information than it needs (the address
), and every place we use UserDetails
must now pass in an address
, even if it doesn’t care about the address.
Instead, we should create a new UserAddress
component:
function UserAddress({ address }) {
return (
<div>
<p>Address: {address}</p>
</div>
);
}
Now, UserDetails
doesn’t depend on the address
, and UserAddress
doesn’t depend on user details. This is in line with the Interface Segregation Principle.
Dependency Inversion Principle (DIP)
The Dependency Inversion Principle states that high-level modules should not depend on low-level modules, both should depend on abstractions. While this principle is generally more related to class dependencies, in React, we interpret it as “depend on props (abstractions), not on concrete implementations (details)”.
Consider a component UserDetail
that fetches and displays the user data:
function UserDetail({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('https://api.example.com/user/' + userId)
.then(response => response.json())
.then(user => setUser(user));
}, [userId]);
return (
user ? <div>{user.name} - {user.email}</div> : 'Loading...'
);
}
In the above example, the UserDetail
component is directly dependent on a specific API endpoint for fetching the user data, violating the Dependency Inversion Principle.
We can refactor this to make the UserDetail
component depend on abstractions (props), not concrete implementations (a specific API call):
function UserDetail({ user }) {
return user ? <div>{user.name} - {user.email}</div> : 'Loading...';
}
// somewhere else in your code
function UserContainer({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('https://api.example.com/user/' + userId)
.then(response => response.json())
.then(user => setUser(user));
}, [userId]);
return <UserDetail user={user} />;
}
In the refactored code, UserDetail
is not concerned with how or where the user
data is fetched from. It simply receives a user
prop and displays the data. This makes the UserDetail
component reusable in different contexts where user data is available, adhering to the Dependency Inversion Principle.
Conclusion
In this post, we explored how to apply SOLID principles to React development. Remember, SOLID principles are general guidelines that can help you create maintainable and scalable code. However, they are not strict rules and might not apply to every situation. It’s crucial to understand the problem at hand and use these principles judiciously.