SOLID Design Principles - React
In the following code, I've extracted a sub-module from one of my React project(🔗 GitHub) and simplified some of the logics and substituted server data with mock data. The module fetches list of posts and displays them.
This innocent-looking app already violates a lot of SOLID principles and is written in a way that hinders software from being functional, maintainable, and robust. At the same time, some of the well-established patterns of React encourage us to follow SOLID principles unwittingly. Let's look at which of the rules are being observed and the others are not.
Single Responsibility Principle
SRP states that "a module should have one, and only one, reason to change." Consider <Posts />
component in the example. It
- fetches data from server
- and displays the fetched data
at the same time. Its responsibility is not cohesive and has more than one reason to change. For example, whenever the interface of server response changes (e.g. { posts: [] }
→ { results: [] }
), when we want to add another column to the table, etc., <Posts />
is affected.
How does <Posts />
that appeases SRP look like?
Now, every module (not only file-level module but also functions and data structure) has one, cohesive responsibility.
usePosts
returns hooks array that fetches post data from the server. I've separateduseEffect
hooks from<Posts />
and made a custom hooks module.columns
defines which portion of the fetched data should be displayed.<Posts />
displays posts in a table view, which consists of a table header and rows.<Post />
displays post items in a row view, which consists of columns for posts' id, title, and user id.StyledPosts
adds style rules to<Posts />
Now each function and data structure participates either in fetching data or in visualizing data, not both.
Interface Segregation Principle
ISP states that a module should avoid depending on things that they don’t use. Joshua Dysiewicz's post concisely bends the principle for React code: "only pass a component props it needs." The statement seems to be justifiable since, in React application, a component is rendered every time the prop's value passed to it by its parent component changes. One can say that props is a sort of dependency.
In the above code, <Post />
doesn't exhaust the props it receives. The interface of server response looks as follows:
{
posts : {
id: number
title: string
body: string
userId: number
tags: string[],
reactions: number
}[]
}
While the entire post
object with 6 keys are passed to each <Post />
as props, <Post />
accesses to only 3 keys(`id`, title
, body
).
Why is not using the entirety of props object a problem? Because it means that the child component has to make decisions as to how to utilize its dependency. This is not desirable because whenever the server changes the interface of the post
object, both parent(<Posts />
) and child(<Post />
) component have to be modified. Imagine, for example, that the server decides to add user's name to post
and group user's name and id into one object. Now, the response to /posts
request would look like this:
{
posts: {
id: number
title: string
body: string
user: {
id: number
name: string
}
tags: string[]
reactions: number
}[]
}
Now, in order for the app to not break, we have to change the source code of <Post />
to look at post.user.id
instead of post.userId
. However, according to how we have rewritten <Post />
component in the above SRP section, <Post />
's one and only responsibility is to display post data in a 3-column format. How the original interface of post
is structured isn't and shouldn't be its concern. Nonetheless, the code as it is now forces <Post />
to be aware of the implementation detail in order to accomplish a single job.
<Post />
's perspective, post
is a public API. How the data is retrieved or structured is part of the implementation detail. Such implementation detail should be handled by <Posts />
, which exposes the API to <Post />
.Let's amend the code:
Now, if we were to change post
's interface to nested one, we only need to change <Posts />
like the following and leave <Post />
intact.
const Posts = () => {
// (...)
return (
<StyledPosts>
// (...)
<tbody>
{posts.map(({ user, id, title }, postIdx) => (
<Post key={postIdx} post={{ userId: user.id, id, title }} />
))}
</tbody>
</StyledPosts>
);
};
Dependency Inversion Principle
DIP is strongly connected to SRP and ISP. A lot of times, the observation of one rule is a natural corollary to that of another. It is true for our example too. The way we declared custom hooks in SRP section also promotes DIP. By importing usePosts
module, the way post data are fetched from the server is abstracted from <Posts />
. As a consequence, the two modules are loosely coupled. usePosts
can be easily replaced with another module with the same interface. For example, it becomes easier to do unit testing, since we only have to manipulate useTodos
to retrieve mock data from local repository and make sure that it returns an array of objects with the same data structure.
Open-Closed Principle
When using function components, Render Props Pattern, and component composition, React developers are most likely to abide by the OCP principle already.
Here's a rather contrived example that intentionally goes against OCP.
I've refactored <Posts />
in a class component style. Then, in order to separate the container component that fetches server data from the presentational component, I had <PostsView />
inherit <PostsController/>
. The two components are tightly coupled. If <PostsController />
changes the name of its state data( posts
) to something else, it inevitably results in modifying the source code of <PostsView />
.
On the contrary, how custom hooks usePosts
names its internal state doesn't have to match how <Posts />
call it.
Even if usePosts
calls the post array items
, <Posts />
can still call the returned value of usePosts()
posts
. In this regard, React hooks pattern is designed with great consideration. By returning state
and setState
function in an array(e.g. [posts, setPosts]
), the way they are called in the place of invocation and of declaration doesn't have to match. On the other hand, if they were to be returned as an object(e.g. { posts, setPosts }
), how they are called does matter across modules.
Liskov Substitution Principle
LSP states that if type S is a subtype of type T, the substitution of all objects of type T with those of type S should not alter a software program's behavior. At first glance, the principle appears to be less relevant to JavaScript, to a programming language that has neither interface
nor type
. However, Robert Martin says that while LSP has been "thought of (...) as a way to guide the use of inheritance, (...) over the years [it] has morphed into a broader principle of software design." As a matter of fact, Konstantin Lebedev says that "in a broader sense, inheritance is simply basing one object upon another object while retaining a similar implementation, and this is something we do in React quite often."
He continues that "a very basic example of a subtype/supertype relationship could be demonstrated with a component built with styled-components library(or any other CSS-in-JS library that uses similar syntax)." In our example, <StyledPosts />
simply adds style to a regular HTML <table>
element without modifying any of <table>
's interface. <StyledPosts />
can be considered a subtype of <table />
. Wherever <table />
is used, it can be substituted by <StyledPosts />
. Thus, applying style rules to a component by creating a new, styled component conforms to LSP.
References
- Clean Architecture: A Craftsman's Guide to Software Structure and Design, C. Martin, Robert. Pearson, 2017.
- "SOLID Principles in React." Dysiewicz, Joshua. 2020.
- "What is an implementation detail?" Khorikov, Vladimir, 2016.
- "Applying SOLID principles in React." Lebedev, Konstantin, 2022.