DailyJS
Published in

DailyJS

Applying SOLID principles in React

Photo by Jeff Nissen on Unsplash

As the software industry grows and makes mistakes, the best practices and good software design principles emerge and conceptualize to avoid repeating the same mistakes in the future. The world of object-oriented programming (OOP) in particular is a goldmine of such best practices, and SOLID is unquestionably one of the more influential ones.

SOLID is an acronym, where each letter represents one out of five design principles which are:

  • Single responsibility principle (SRP)
  • Open-closed principle (OCP)
  • Liskov substitution principle (LSP)
  • Interface segregation principle (ISP)
  • Dependency inversion principle (DIP)

In this article, we’ll talk about the importance of each principle and see how we can apply the learnings from SOLID in React applications.

Before we begin though, there’s a big caveat. SOLID principles were conceived and outlined with object-oriented programming language in mind. These principles and their explanation heavily rely on concepts of classes and interfaces, while JS doesn’t really have either. What we often think of as “classes” in JS are merely class look-alikes simulated using its prototype system, and interfaces aren’t part of the language at all (although the addition of TypeScript does help a bit). Even more, the way we write modern React code is far from being object-oriented — if anything, it feels more functional.

The good news though, software design principles such as SOLID are language agnostic and have a high level of abstraction, meaning that if we squint hard enough and take some liberties with interpretation, we’ll be able to apply them to our more functional React code.

So let’s take some liberties.

Single responsibility principle (SRP)

The original definition states that “every class should have only one responsibility”, a.k.a. do exactly one thing. This principle is the easiest to interpret, as we can simply extrapolate the definition to “every function/module/component should do exactly one thing”.

Out of all five principles, SRP is the easiest to follow, but it’s also the most impactful one as it drastically improves the quality of our code. To ensure our components do one thing, we can:

  • break large components that do too much into smaller components
  • extract code unrelated to the main component functionality into separate utility functions
  • encapsulate connected functionality into custom hooks

Now let’s see how we can apply this principle. We’ll start by considering the following example component that displays a list of active users:

Although this component is relatively short now, it is already doing quite a few things — it fetches data, filters it, renders the component itself as well as individual list items. Let’s see how we can break it down.

First of all, whenever we have connected useState and useEffect hooks, it's a good opportunity to extract them into a custom hook:

Now our useUsers hook is concerned with one thing only - fetching users from API. It also made our main component more readable, not only because it got shorter, but also because we replaced the structural hooks that you needed to decipher the purpose of with a domain hook the purpose of which is immediately obvious from its name.

Next, let’s look at the JSX that our component renders. Whenever we have a loop mapping over an array of objects, we should pay attention to the complexity of JSX it produces for individual array items. If it’s a one-liner that doesn’t have any event handlers attached to it, it’s totally fine to keep it inline, but for a more complex markup it could be a good idea to extract it into a separate component:

Just as with a previous change, we made our main component smaller and more readable by extracting the logic for rendering user items into a separate component.

Finally, we have the logic for filtering out inactive users from the list of all users we get from an API. This logic is relatively isolated and it could be reused in other parts of the application, so we can easily extract it into a utility function:

At this point, our main component is short and straightforward enough that we can stop breaking it down and call it a day. However, if we look a bit closer, we’ll notice that it’s still doing more than it should. Currently, our component is fetching data and then applying filtering to it, but ideally, we’d just want to get the data and render it, without any additional manipulation. So as the last improvement, we can encapsulate this logic into a new custom hook:

Here we created useActiveUsers hook to take care of fetching and filtering logic (we also memoized filtered data for good measures), while our main component is left to do the bare minimum - render the data it gets from the hook.

Now depending on our interpretation of “one thing”, we can argue that the component is still first getting the data, and then rendering it, which is not “one thing”. We could split it even further, calling a hook in one component and then passing the result to another one as props, but I found very few cases where this is actually beneficial in real-world applications, so let’s be forgiving with the definition and accept “rendering data the component gets” as “one thing”.

To summarize, following the single-responsibility principle, we effectively take a large monolithic piece of code and make it more modular. Modularity is great because it makes our code easier to reason about, smaller modules are easier to test and modify, we’re less likely to introduce unintentional code duplication, and as a result, our code becomes more maintainable.

It should be said, that what we’ve seen here is a contrived example, and in your own components you may find that the dependencies between different moving parts are much more intertwined. In many cases, this could be an indication of poor design choices — using bad abstractions, creating universal do-it-all components, incorrectly scoping the data, etc., and thus can be untangled with a broader refactoring.

Open-closed principle (OCP)

OCP states that “software entities should be open for extension, but closed for modification”. Since our React components and functions are software entities, we don’t need to bend the definition at all, and instead, we can take it in its original form.

The open-closed principle advocates for structuring our components in a way that allows them to be extended without changing their original source code. To see it in action, let’s consider the following scenario — we’re working on an application that uses a shared Header component on different pages, and depending on the page we're at, Header should render a slightly different UI:

Here we render links to different page components depending on the current page we’re at. It’s easy to realize that this implementation is bad if we think about what will happen when we start adding more pages. Every time a new page is created, we’ll need to go back to our Header component and adjust its implementation to make sure it knows which action link to render. Such an approach makes our Header component fragile and tightly coupled to the context in which it's used, and it goes against the open-closed principle.

To fix this problem, we can use component composition. Our Header component doesn’t need to concern itself with what it will render inside, and instead, it can delegate this responsibility to the components that will use it using children prop:

With this approach, we completely remove the variable logic that we had inside of the Header and now can use composition to put there literally anything we want without modifying the component itself. A good way of thinking about it is that we provide a placeholder in the component that we can plug into. And we're not limited to one placeholder per component either - if we need to have multiple extension points (or if the children prop is already used for a different purpose), we can use any number of props instead. If we need to pass some context from the Header to components that use it, we can use the render props pattern. As you can see, composition can be very powerful.

Following the open-closed principle, we can reduce coupling between the components, and make them more extensible and reusable.

Liskov substitution principle (LSP)

Overly simplified, LSP can be defined as a type of relationship between objects where “subtype objects should be substitutable for supertype objects”. This principle heavily relies on class inheritance to define supertype and subtype relationships, but it’s not very applicable in React since we hardly ever deal with classes, let alone class inheritance. While moving away from class inheritance would inevitably bend this principle into something completely different, writing React code using inheritance would be deliberately creating bad code (which React team highly discourages), so instead, we’re just going to skip this principle.

Interface segregation principle (ISP)

According to ISP, “clients should not depend upon interfaces that they don’t use.” For the sake of React applications, we’ll translate it into “components shouldn’t depend on props that they don’t use”.

We’re stretching the definition of the ISP here, but it’s not a big stretch — both props and interfaces can be defined as contracts between the object (component) and the outside world (the context in which it’s used), so we can draw parallels between the two. In the end, it’s not about being strict and unyielding with the definitions, but about applying generic principles in order to solve a problem.

To better illustrate the problem ISP is targeting, we’ll use TypeScript for the next example. Let’s consider the application that renders a list of videos:

Our Thumbnail component that it uses for each item might look something like this:

The Thumbnail component is quite small and simple, but it has one problem - it expects a full video object to be passed in as props, while effectively using only one of its properties.

To see why that’s problematic, imagine that in addition to videos, we decide to display thumbnails for live streams as well, with both kinds of media resources mixed in the same list.

We’ll introduce a new type defining a live stream object:

And this is our updated VideoList component:

As you can see, here we have a problem. We can easily distinguish between video and live stream objects, but we cannot pass the latter to the Thumbnail component because Video and LiveStream are incompatible. First, they have different types, so TypeScript would immediately complain. Second, they contain the thumbnail URL under different properties - video object calls it coverUrl, live stream object calls it previewUrl. That's the crux of the problem with having components depend on more props than they actually need - they become less reusable. So let's fix it.

We’ll refactor our Thumbnail component to make sure it relies only on props it requires:

With this change, now we can use it for rendering thumbnails of both videos and live streams:

The interface segregation principle advocates for minimizing dependencies between the components of the system, making them less coupled and thus more reusable.

Dependency inversion principle (DIP)

The dependency inversion principle states that “one should depend upon abstractions, not concretions”. In other words, one component shouldn’t directly depend on another component, but rather they both should depend on some common abstraction. Here, “component” refers to any part of our application, be that a React component, a utility function, a module, or a 3rd party library. This principle might be difficult to grasp in the abstract, so let’s jump straight into an example.

Below we have LoginForm component that sends user credentials to some API when the form is submitted:

In this piece of code, our LoginForm component directly references the api module, so there's a tight coupling between them. This is bad because such dependency makes it more challenging to make changes in our code, as a change in one component will impact other components. The dependency inversion principle advocates for breaking such coupling, so let's see how we can achieve that.

First, we’re going to remove direct reference to the api module from inside the LoginForm, and instead, allow for the required functionality to be injected via props:

With this change, our LoginForm component no longer depends on the api module. The logic for submitting credentials to the API is abstracted away via onSubmit callback, and now it is the responsibility of the parent component to provide the concrete implementation of this logic.

To do that, we’ll create a connected version of the LoginForm that will delegate form submission logic to the api module:

ConnectedLoginForm component serves as a glue between the api and LoginForm, while they themselves remain fully independent of each other. We can iterate on them and test them in isolation without worrying about breaking dependent moving pieces as there are none. And as long as both LoginForm and api adhere to the agreed common abstraction, the application as a whole will continue working as expected.

In the past, this approach of creating “dumb” presentational components and then injecting logic into them was also used by many 3rd party libraries. The most well-known example of it is Redux, which would bind callback props in the components to dispatch functions using connect higher-order component (HOC). With the introduction of hooks this approach became somewhat less relevant, but injecting logic via HOCs still has utility in React applications.

To conclude, the dependency inversion principle aims to minimize coupling between different components of the application. As you’ve probably noticed, minimizing is somewhat of a recurring theme throughout all SOLID principles — from minimizing the scope of responsibilities for individual components to minimizing cross-component awareness and dependencies between them.

Conclusion

Despite being born out of problems of the OOP world, SOLID principles have their application well beyond it. In this article, we’ve seen how by having some flexibility with interpretations of these principles, we managed to apply them to our React code and make it more maintainable and robust.

It’s important to remember though, that being dogmatic and religiously following these principles may be damaging and lead to over-engineered code, so we should learn to recognize when further decomposition or decoupling of components stands to introduce complexity for little to no benefit.

Originally published at https://konstantinlebedev.com.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Konstantin Lebedev

Konstantin Lebedev

Full-time learner, part-time educator. Find more programming tutorials at https://konstantinlebedev.com