Applying SOLID principles in React

TL;DR
In software development, SOLID is a set of five principles that transforms messy code into clean code. Each letter represents a best practice that every developer should know. Although SOLID was meant for object-oriented programming (OOP), it is language-agnostic and can be applied to any programming language— including React!
The SOLID principles are:
-
Single responsability
-
Open-closed
-
Liskov substitution
-
Interface segregation
-
Dependency inversion
1. Single responsability principle
“Every class should have only one responsability”
Even though it says "class", think instead: components, functions, or hooks.
So what is the issue of having many responsabilities in a component? Well, potential bugs could appear. Modifying one of its responsability could affect the others and arise bugs without you knowing.
Here a function component that violates single responsability principle:
export function TodoList () {
// 1. responsability for managing states
const [data, setData] = useState<TodoType>([])
const [isFectching, setIsFetching] = useState(true)
// 2. responsability for data fetching
useEffect(() => {
axios
.get<TodoType[]>("https://someurl.com/todos")
.then((res) => setData(res.data))
.catch((e) => console.log(e))
.finally(() => setIsFetching(false))
} , [])
if (isFectching) {
return <p> ...loading </p>
}
// 3. responsability for rendering
return (
<ul>
{data.map((todo) => {
return (
<li>
<span>{todo.id}</span>
<span>{todo.title}</span>
</li>
)
})}
</ul>
)
}
Typically, if you have a component that uses useEffect
and useState
together, then it may mean that you need a custom hook.
// custom hook
function useFetchTodo() {
const [todo, setTodo] = useState<TodoType>([])
const [isFectching, setIsFetching] = useState(true)
useEffect(() => {
axios
.get<TodoType[]>("https://someurl.com/todos")
.then((res) => setData(res.data))
.catch((e) => console.log(e))
.finally(() => setIsFetching(false))
} , [])
return { todo, isFetching }
}
export function TodoList () {
const { todo, isFetching } = useFetchTodo()
if (isFectching) {
return <p> ...loading </p>
}
// 1. responsability for rendering
return (
<ul>
{data.map((todo) => {
return (
<li>
<span>{todo.id}</span>
<span>{todo.title}</span>
</li>
)
})}
</ul>
)
}
Notice TodoList()
component now has a single responsability: rendering the component.
If you want to modularize even more, best practice is creating a ./hook directory to store all hooks. This way, the main component TodoList()
does not have to know what the useFetchTodo
imports is using and its logic, it only knows how to use it.
2. Open-closed principle
“Software entities should be open for extension, but closed for modification"
This principle says should not modify the source code of the component, instead should somehow modify it from outside, make it extensible.
Spoilers, we could achieve this using the special prop: children, allowing us extend the component from outside.
Consider the following sceneario where we conditionally render eiter a link
or button
tag according to the prop type
.
What if we want to add withAnalyticsButton
, you see the problem?
function Title ({
title,
type,
href,
buttonText,
onClick
// props for withAnalyticsButton
}: Props) {
return (
<div>
<h1>{title}</h1>
{type === "withLinkButton" && (
<a href={href}></a>
)}
{type === "withNormalButton" && (
<button onClick={onClick}>{buttonText}</button>
)}
{type === "withAnalyticsButton"
... withAnalyticsButton
}
</div>
)
}
Yep, the only way is to modify the component in order to add the new button, violating the open-closed principle.
How to fix this? We use the power of children
function Title ({ title, children }: Props) {
return (
<div>
<h1>{title}</h1>
{children}
</div>
)
}
Now we can extend this component:
function TitleWithLink ({ title, href, buttonText }) {
<Title title={title}>
<a href={href}>{buttonText}</a>
</Title>
}
function TitleWithAnalyticsButton ({title, sendAnalytics, buttonText}) {
<Title title={title}>
<button onClick={sendAnalytics}>
{buttonText}
</button>
</Title>
}
... and more buttons
Good to know
-
Note that while
children
only allows us one placeholder, we are not limited by this. If we need multiple extensions and thechildren
props is already in used, we can follow the render props patterns. -
Here an example of components following open-close principle in production.
3. Liskov substitution principle
“Subtype objects should be subtitutable for supertype objects”
Liskov suggest that we should design objects that can be easily substitutable. If we have a Dog
class that extends Animal
class then we should be able to replace Dog
with Animal
without breaking anything. This sounds like more applicable in object-oriented but can be done also in React.
In React, a component is a function that returns JSX (HTML like). If we console log it, we will notice that this is represented as a Javascript object, hence Liskov substitution is applicable in React components.
This is how JSX output looks like:
function App() {
return (
<Button title="Submit">
Click me!
</Button>
);
}
// console.log(App()) ->
{
type: Button,
props: { // <- 👀 notice props
title: "Submit",
children: "Click me!"
},
key: null,
ref: null,
_owner: null,
_store: {}
}
Let's say we have two components. Clearly RedButton
extends from Button
component. Notice RedButton
accepts a boolean isBig
but Button
does not.
type ButtonProps = {
children: React.ReactNode
color?: string
size?: string
}
function Button ({ children, color, size }: ButtonProps) {
return (
<button style={{color, fontSize: size === 'xl' ? '32px' : '16px'}} >
{children}
</button>
)
}
type RedButtonProps = {
children: React.ReactNode
isBig?: boolean
}
function RedButton ({ children, isBig }: RedButtonProps) {
return (
<Button size={isBig ? 'xl' : 'sm'} color='red'>
{children}
</Button>
)
}
function App () {
return (
<RedButton isBig={true}>
Send
</RedButton>
)
}
Now, for some reason our UI designer tells us that instead of using Redbutton
, we should use Button
. As junior developers, we might be tempted to just change the component's name and call it a day.
function App () {
return (
<Button isBig={true}>
Send
</Button>
)
}
Doing such change will break our app since Button
does not have isBig
prop, they are not interchangeable. And if you are working without typescript, you will never know.
To fix this, we should always use the same props and types as the supertype object.
// ❌ 'isBig' is not a prop of Button
function RedButton ({ children, isBig }) {
return (
<Button size={isBig ? 'xl' : 'sm'} color='red'>
{children}
</Button>
)
}
// ✅ 'size' is a prop of Button
function RedButton ({ children, size }) {
return (
<Button size={size} color='red'>
{children}
</Button>
)
}
If we try it again, interchanging will not break our app. Of course, this is a simple example but imagine something more complex component. This can save you a lot of time.
4. Interface segregration principle
“Clients should not depend on interfaces that they do not use.”
In React, this principle states that we should only pass the necessary props to our components. However, developers sometimes ignore this and pass the entire object as props into the component, which is bad practice.
Example 1
Here we have a Thumbnail
component with an object contract. The problem is that we are passing the whole object Video
while the component only uses the coverUrl
.
type Props = {
video: Video // Video: { ...50 entries }
}
function Thumbnail ({ video }: Props) {
return <img src={video.coverUrl} />
}
Now, imagine you want to test this component, you would need to mock several Video
objects and pass it to Thumbnail
component. What if the Video
object has 50 entries? Do you see the issue? The contract of this component is too big to be mocked.
We could fix this by passing only the necessary props.
type Props = {
coverUrl: string
}
function Thumbnail ({ coverUrl }: Props) {
return <img src={coverUrl} />
}
Now testing this is so easy; we only need to pass a string.
Example 2
Here we have several components that are part of the main component Post
type TPost = {
title: string
author: Author
createdAt: Date
}
// main component
function Post ({ post }: TPost) {
return (
<div>
<PostTitle post={post} />
<span>{post.author.name}</span>
<PostDate post={post} />
</div>
)
}
function PostTitle ({ post }: TPost) {
return <h1>{post.title}</h1>
}
function PostDate ({ post }: TPost) {
return <time>{post.createdAt.toString()}</time>
}
We see the same issue where the PostTitle
component is passed the entire Post
object, which doesn't make sense. To fix this, we simply pass the necessary props.
function PostTitle ({ title }: { title: string }) {
return <h1>{title}</h1>
}
function PostDate ({ createdAt }: { createdAt: Date }) {
return <time>{createdAt.toString()}</time>
}
5. Dependency inversion principle
“One should depend upon abstractions, not concretions”
Basically one component, classes, utility functions, or 3rd party library shouldn't directly depend on another component, but rather they both should depend on some common abstraction.
Example 1
Here, notice LoginForm
component depends on api
module (since we imported api
), they both coupled each other.
import api from '~/api'
function LoginForm () {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
await api.login(email, password)
}
return (
<form onSubmit={handleSubmit}>
<input ... />
<input ... />
<button type="submit">Log in</button>
</form>
)
}
This is bad practice because dependency adds complexity and makes it harder to modify the code. How?
For example, imagine we want to reuse this LoginForm
elsewhere in our app with an extra logic api.sendAnalytics()
function in the handleSubmit
. We couldn't reuse this component at all because modifying it would cause analytics to be sent everytime this component is used. But that's not what we want—we want control over when and where analytics are sent.
We have two possible solutions: we could either inject via props or context.
In this case we'll use via props, our LoginForm
is no longer coupled with onSubmit
. Note LoginForm
was changed to simply Form
.
type Props = {
onSubmit: (email: string, password: string) => Promise<void>
}
// passing 'onSubmit' via props
function Form ({ onSubmit }: Props) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
await onSubmit(email, password)
}
return (
<form onSubmit={handleSubmit}>
<input ... />
<input ... />
<button type="submit">Log in</button>
</form>
)
}
Let the parent component responsible for importing the module and passing it via props to Form
. Thus, following the 1st principle single reponsability as well.
import api from '~/api'
function FormWithLogin () {
const handleSubmit = async (email, password) => {
await api.login(email, password)
}
return <Form onSubmit={handleSubmit} />
}
function FormWithLoginAnalytics () {
const handleSubmit = async (email, password) => {
await api.login(email, password)
api.sendAnalytics()
}
return <Form onSubmit={handleSubmit} />
}
Example 2
In this example, a 3rd party library SWR
and Todo
component are coupled.
import SWR from "swr"
async function fetcher (url : string) {
const res = await fetch(url)
return res.json()
}
function Todo () {
const { data } = useSWR("https://someapi.com/todos", fetcher)
if (!data) return <p>Loading...</p>
return (
<ul>
{data.map((todo) => (
<li>
<span>{todo.id}</span>
<span>{todo.title}</span>
</li>
)
)}
</ul>
)
}
To solve this issue of dependency coupling, we should instead create an abstraction in order to inject the dependency.
Create in a separate file a custom hook abstracting useSWR
with fecther
passed as props.
import SWR from "swr"
interface UserData<T> {
key: string
fetcher: () => Promise<T>
}
interface Response<T> {
data: T | undefined
error: string | undefined
isValidating: boolean
}
export function useData <T>({ key, fetcher }: UserData<T>): Response<T> {
const { data, error, isValidating } = useSWR<T, string>(key, fetcher)
return { data, error, isValidating }
}
With this abstraction, fetcher
can be anything—from graphql, mongodb, localstorage, cookies, mock, who knows. All we know these fetchers share the same contract, which is returning a Promise<ResponseType>
.
type ResponseType = {
id: number
title: string
}
// api
function fetcher (): Promise<ResponseType[]> {
const url = "https://someapi.com/todos"
const res = await fetch(url)
return res.json()
}
// mocked
function fetcher (): Promise<ResponseType[]> {
return [{ id: 1, title: "hello" }, { id: 2, title: "world"}]
}
// localstorage
function fetcher (): Promise<ResponseType[]> {
const posts = localStorage.getItem('posts')
return posts ? JSON.parse(posts) : []
}
And then we can inject fetcher
into Todo
component, this case via context.
type ResponseType = {
id: number
title: string
}
function Todo () {
const { fetcher } = useGlobalContext()
const { data } = useData<ResponseType[]>({ key: "/todos", fetcher})
if (!data) return <p>Loading...</p>
return (
<ul>
{data.map((todo) => (
<li>
<span>{todo.id}</span>
<span>{todo.title}</span>
</li>
)
)}
</ul>
)
}
In conclusion, try to avoid importing modules or 3rd party libraries such as redux, zustand, or tanstack query, in your most basic UI components (aka atomic design components). Apply the Dependency Inversion Principle.