React is an extremely easy to use frontend library. It allows prototyping and iteration at a neck-breaking speed. But, when developing large-scale production apps. it is important to carefully design the architecture and keep the design clear and simple. In this article I will go into some of the tools and principles you can use to architect your React projects.
Why is architecture so important?
It is easy to gloss over many architecture decisions and principles when starting from ground up. By the end of the sprint, you already have working authentication, homepage and stakeholders are happy. Your CI/CD scripts publish everything for you, releases are instant and no crashes were reported.
However, after a few sprints, you notice that the productivity of the team deteriorates. Your CI/CD scripts are failing due to
Undefined not an object: youHaveNoIdeaWhatThisFileDoes.js:442, and the deployment process is now manual since no one wants to touch that with a 10-foot stick. New hires are struggling to understand the codebase and seniors are burnt out.
These, along with many others, are symptoms of a bad architecture. Whether it could not scale correctly, or is just an unreadable mess, does not matter: bad architecture is the single biggest killer of software projects. In this article I will show you some of the tools and design patters you can use to prevent these from happening.
The foundation of any architecture are the abstraction layers and the principles of encapsulation. Abstraction layers are used to incrementally increase the capabilities of a software entity while keeping the interfaces between them clear. Abstraction layers can span and scale in both directions:
I hope these illustrations make it clear. Abstraction layers are very subjective and will continue infinitely to the left (Operating System, Firmware, Transistors, Electrons, etc.) and to the right (economy, politics, weather on Mars, etc.) and an abstraction layer can contain an infinite amount of abstraction layers within. It is up to the architect to figure out what layers the project will need, according to its requirements and stakeholders.
Notice that abstraction only points upward (left in the diagrams). We do not want anything in the HTML/CSS layer to know about the business logic. This will let us change the business logic (which you will do quite often) without breaking or changing anything on the lower levels.
Now, once you understand the abstraction layers, we can proceed to the dependency rule.
Source code dependencies must point only inward, toward higher-level dependenciesRobert C. Martin AKA Uncle Bob
This is the abstraction level direction, just applied directly to the source code. You never want your upper level policies depending on lower-level implementation detail. One case where you would immediately notice its drawbacks is rewriting a React app to React Native. In a sound architecture this will be merely a matter of rewriting a few dumb components to render
View instead of
div. But, most often, this results in a new app written from scratch as the architecture did not allow such changes.
It is easy for me to sit around and write articles how you should pay attention to your architecture, but is there something you could actually do, especially if the system is already under development? Of course there is.
madge is a command-line tool that lets you graph the dependencies of your project so you can see everything from a high level overview. Get it installed right now:
npm i -g madge
Open one of the JS projects you are working right now and run
madge ./. What it does is walk through every JS file in the directory and enumerate its dependencies. Here is part of its output on my RE:Cards project:
Using this tool, I can easily see that my
App module depends on a bunch of components, the Redux store, and
auth actions. You can also see that the
auth actions depend on the
firebase low-level functions, which is desired.
This is useful, but still not what we are looking for. To make this tool really useful, you also need to install Graphviz. Using this tool,
madge will generate an SVG graph of your dependencies which is much more readable than the console output. For Linux users:
$ sudo apt-get install graphviz or your package manager of choice
$ brew install graphviz
For windows users, there is no package available. What you need to do is download the installation of Graphviz from here and add it to your
PATH environment variable.
After you have installed it, you can run this command in your project:
$ madge --image graph.svg ./
This will enumerate all dependencies and produce a
graph.svg file that can be viewed with any image viewer or a browser. Now, I got quite a big file:
Keep in mind, I ran it on quite a small project: your tree will likely be bigger. Using this tree, you can easily trace your dependencies, see what modules will need changing if you make changes to another one and what refactoring efforts are currently required.
Let me explain the colouring on this tree. The nodes marked with blue are modules that both depend on something and have something dependant. This will be the majority case. The ones coloured in green are leafs. These modules are someone’s dependencies, but have no dependencies of their own. Lastly, the red colour indicates a dependency cycle. A dependency cycle occurs when a module depends on itself through a chain of other modules. This is an example from my repo:
You can see that the
auth.js file depends on
listeners.js depends on
auth.js. This is something that should never occur. Debugging this kind of cycles is a highway to hell and good luck to you if you also have some in your codebase. This is generally something that you need to address before all else. Luckily, with the use of the
madge tool, you can integrate it into your CI/CD pipelines and make sure that no commit introduces (intentionally or by accident) a cycle into the codebase. The command you are interested in is the
madge --circular ./:
By setting this to run on every PR you can make sure there are no circular dependencies in the project. Yet another PR check to make your life easier!
The use of the
madge tool does not end there: you can automatically check the dependencies of new modules and assign code reviews based on the people who wrote the dependant modules. The possibilities are endless, but out of the scope of this article.
Stable and unstable dependencies
Why did I (and maybe you) went though all the trouble of setting up
madge and looking at the dependency tree? Weill this has to do with the next principle I am going to talk about: the stability level of modules.
The stability in question has nothing to do with bugs or crashes: this is a metric of change.
Have a look at these stones. Do they look stable to you? Definitely not. However, unless something moves them (air, humans, aliens) they would standstill. So, stability has nothing to do with movement or position at all: this is a measure of how hard it is to change (move) something. Under this definition, the stones are indeed very unstable. You could say nothing depends on them.
These stones, on the other hand, are very stable. To move them, not only you will need specialized equipment, but you will have to fight bewildered activists who do not want to see their monument moving anywhere. You could say that English depend on these stones.
What does this have to do with software modules at all? Everything, I say. Just like stones, a software system will have stable modules and unstable modules. A module that depends on other modules but has no dependent modules is considered unstable: it is easy to change it if required. On the other hand, a module which is dependant upon in other modules is stable, and, therefore, hard to change. To make changes to it, you will have to look up all dependant modules and adjust them as needed.
A good system design will have a mix of both: some parts of the system need to be changed frequently (business level policies) while others (low-level implementation details) will not change as frequently. We say that modules that require frequent change are volatile and should, therefore be unstable. These modules will depend on the stable parts of the system, which are not volatile.
Your task as an architect is to figure out which parts of the system will change frequently (and by which stakeholders) and which parts will not. Of course, in an ideal world every part of the system should be easy to change, but, thankfully, we do not live in one. You have to carefully balance stable and unstable modules, because one cannot exist without the other.
How do you measure the stability level of a module? Using a simple formula:
The I is the instability metric. It is measured from 0 to 1, where 0 is an absolutely stable component, and 1 is the unstable.
Depend in the direction of stability
Now, using the
madge tool and the stability metric, we can devise another version of the dependency rule: depend in the direction of stability.
This is as easy as this: if you import a module with the stability lower than the one you are working on, take a step back. We want the modules to depend on modules that are more stable. If a stable component depends on an unstable one, it makes it stable and hard to change as well. This is something that you can easily catch with the
madge tool. Consider this example from my codebase:
Here we have the
Thumbnail component on the left. It has 1 dependent module and 3 dependencies, this gives it an instability metric of 3/4, which makes it a highly unstable component. The
FlipCard components only have dependants and no dependencies of their own, which makes them very stable. And the dependency direction points in the direction of stability, which is desired. The
FrontSide component, on the other hand, is unstable, since it has many dependencies (could not fit them on the screen) and only 1 dependant. This is something I have to address while refactoring.
I hope this article made you understand 1 thing: it does not matter how do you choose to structure your app, as long as the dependency rules are honoured. It does not matter if you are working with smart/dumb components, Business Logic Components, Atoms/Molecules or anything else. It is as simple as that: code that follows the dependency rule will be understandable and easy to work with, and the one which does not will be an abomination, even if you applied all of the patterns you could lay your hands on.
React is a developer’s paradise when it comes to designing systems. By providing no conventions and limits, it lets you set up your modules in any way, shape, or form and set up dependencies as you see fit. This is why the dependency rules are so important in React apps: by taking away the safety wheels it lets you do all kinds of tricks, but it is gonna hurt like hell when (not if, when) you fall. Just remember what you learned, reflect on your mistakes and refactor away.
I am not talking about several other principles, such as the DIP (dependency inversion principle) because I think that frontend is still a much more simpler system than backend in terms of abstractions and interoperability, thus it does not need all the paradigms of one. But, if you are interested to learn more about systems design, I urge you to read the resources section at the bottom.
Thank you for reading my opinionated article on React systems design, I hope you liked it.
This article was inspired by Robert C. Martin’s book Clean Architecture, part of the Clean – (Code, Coder, Architecture, Agile) series. I strongly suggest these as a must-read for anyone who considers themselves anything else than an entry-level junior developer. Here are the links:
- Robert C. Martin, Clean Architecture (affiliate link)
- Robert C. Martin, Clean Architecture (the screw you Micheal, non-affiliate link)