A SOLID way to React
Hello, fellow React developers!
Before my time at 7shifts, I worked on backend communication and network solutions, primarily in C++, where SOLID principles rule. Over the past year, I’ve been diving deep into the world of React, and I’ve found myself pondering: How can we apply the time-tested SOLID principles from the Object-Oriented Programming (OOP) world to our React codebases?
For those of you who need a refresher, the SOLID principles were created (discovered? written down?) by Robert C. Martin (Uncle Bob). The SOLID principles are design principles for writing maintainable, scalable, readable, testable, clean, object-oriented code. They stand for the following:
- S — Single Responsibility Principle
- O — Open-Closed Principle
- L — Liskov Substitution Principle
- I — Interface Segregation Principle
- D — Dependency Inversion Principle
For each of these principles, I will lay out the traditional interpretation and how they apply to frontend code written in React.
1. Single Responsibility Principle (SRP)
Traditional Definition: A class should have one, and only one, reason to change.
React Interpretation: A component should have only one responsibility.
Example: Consider a component responsible for fetching data and displaying it.
export const DailyPlanner = () => {
const [data, setData] = useState();
useEffect(() => {
const data = Promise.all([API.fetchTodos(), API.fetchGoals()]);
setData(data);
}, []);
return (
<div>
<Todos data={data} />
<Goals data={data} />
</div>
);
};
This is a relatively contrived example that renders some Todos
and Goals
, but hopefully you can see how this breaks the SRP.
The DailyPlanner
component is responsible for both fetching the data, and displaying it. Two things. This component is doing too many things! The fetching of the data should probably be extracted to a custom hook, even if it’s quite small.
export const DailyPlanner = () => {
const { todos, goals } = useData();
return (
<div>
<Todos todos={todos} />
<Goals goals={goals} />
</div>
);
};
Now our app is only responsible for rendering the Todos/Goals.
1 principle down, 4 to go.
2. Open-Closed Principle (OCP)
Traditional Definition: Software entities should be open for extension but closed for modification.
React Interpretation: Components should be extendable through props without needing to modify the original component.
Example: Let’s take a look at the Goals
component that I wrote that is being rendered by our DailyPlanner
.
export const Goals = (goals) => {
return (
<div>
<h1>Goals</h1>
<ul>
{goals.items &&
goals.items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
};
What if the Todos
that I want to render also have an id
and a name
? Well the only way I can use the Goals
component to render the Todos
is to modify it. This component should have been written in such a way that modification was not required, only extension. We could rewrite this component to be extendable without the need for modification
export const List = (items, name) => {
return (
<div>
<h1>{name}</h1>
<ul>
{items &&
items.map((item) => <li key={item.id}>{item.name}</li>)}
</ul>
</div>
);
};
Now, our List
component can be used for both Goals
and Todos
and our component now looks like this:
export const DailyPlanner = () => {
const { todos, goals } = useData();
return (
<div>
<List items={todos.items} name={'Todos'} />
<List items={goals.items} name={'Goals'}/>
</div>
);
};
Using an open source design system, such as 7shifts’ sous-chef library, gives us this added benefit: components are closed for modification but highly extensible. Having to open a PR to modify a sous-chef component might be the best example of “closed for modification,” however the components provided are so highly extensible that having to modify a sous-chef component is quite rare.
Okay, 2 down, 3 to go.
3. Liskov Substitution Principle (LSP)
Traditional Definition: Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T
Or for those of us who can’t understand that: Every subclass or derived class should be substitutable for their base or parent class.
This one is a bit different as React isn’t big on inheritance. However, the LSP does still find its place in front end code when we think about TypeScript and type substitutions.
The React interpretation is the following:
React Interpretation: Components should be renderable by a parent as long as they satisfy the required type.
Example: We modified the List component to handle both Goals and Todos. This required both components to have an items
property, a rule we can enforce using TypeScript
:
export const List = (items: Items, name: string) => {
return (
<div>
<h1>{name}</h1>
<ul>
{items.map((item) => <li key={item.id}>{item.name}</li>)}
</ul>
</div>
);
};
Now, anyone can render a List
component as long as they provide items
of type Items
and a name that is of type string
.
3 down, 2 to go.
4. Interface Segregation Principle (ISP)
Traditional Definition: A client should never be forced to implement an interface that it doesn’t use, or clients shouldn’t be forced to depend on methods they do not use.
React Interpretation: Many, specific-custom hooks are better than one general-purpose custom hook.
This principle is closely related to the SRP, but whereas the SRP is concerned with a component being responsible for one thing, the ISP is more concerned about interfaces that provide too many things.
A hook can be seen as an interface to the data layer, and that interface should be small and specific, rather than large and general.
Example: If we look at our useData
hook, it’s returning us both the todos
and the goals
. Is that necessary? This interface could be split up since the todos
and the goals
don’t need to be fetched together. We could refactor our app a bit to split them out.
export const DailyPlanner = () => {
const { todos } = useTodos();
const { goals } = useGoals();
return (
<div>
<List items={todos.items} name={'Todos'} />
<List items={goals.items} name={'Goals'}/>
</div>
);
};
Now we have two custom specific hooks, rather than one large general purpose hook. This is beneficial because perhaps some other component in our application needs to use the goals
as well. Now, that component can just use the useGoals
hook, instead of our previous useData
hook, and we’ve saved ourselves some extra data fetching since the new hook serves a more narrow purpose.
Again, this is a pretty contrived example, but I think we’ve all seen some hooks in our code base that return a lot of data.
It might be worth considering, does every component that uses this hook actually use everything that is returned? If they don’t, that might be an ISP smell. Could the hook be split up into smaller hooks that serve a narrower purpose?
4 down, 1 to go!
5. Dependency Inversion Principle (DIP)
Traditional Definition: One should depend on abstractions, not concretions
React Interpretation: Components should not care about how or where data comes from
Example: If we look back to how our DailyPlanner
used to look, we can see where this principle most applies:
export const DailyPlanner = () => {
const [data, setData] = useState();
useEffect(() => {
const data = Promise.all([API.fetchTodos(), API.fetchGoals()]);
setData(data);
}, []);
return (
<div>
<Todos data={data} />
<Goals data={data} />
</div>
);
};
As can be seen above, our component is responsible for both where the data comes from and how it’s getting it. When we abstracted this logic into a custom hook, we fixed our DIP problem. Now the DailyPlanner
is no longer concerned about where the data comes from, as long as it is provided according the the correct type it can be used (LSP hint hint, nudge nudge)!
Now our code has high cohesion and low coupling. Goals
are not reliant on todos
. Todos are not reliant on goals
. All the DailyPlanner
component is responsible for is rendering other components. And our hooks are only responsible for getting a specific piece of data. I think we can all agree this looks much better:
export const DailyPlanner = () => {
const { todos } = useTodos();
const { goals } = useGoals();
return (
<div>
<List items={todos.items} name={'Todos'} />
<List items={goals.items} name={'Goals'}/>
</div>
);
};
And as an aside, it will be much easier to test the three smaller components and two custom hooks than the original component.
Conclusion
Transitioning from backend communication and network solutions to the vibrant world of React has been a journey of continuous learning for me. While SOLID principles have their roots in OOP, their core ideas can be adapted to elevate our React codebases.
A developer somewhere sometime once said, “There’s no such thing as perfect code, only better code.” By understanding and applying SOLID principles to React, I think we can all strive for that ‘better code.’