A couple of years ago at FullStory it was decided that we would convert our app from our home-grown JS framework into React. The work began optimistically and not long after we had a bunch of shiny React code in our codebase. Fast forward to now and we have thousands of React components. We’ve learned a lot along the way and one of the most important things is that simply converting from one framework to another wasn’t enough to unlock the full potential that we had hoped for from making the switch. In order to gain the full benefits of the conversion efforts we realized we would need to revisit some basic principles of software architecture and React’s core principles. We then implemented this research by creating 3 distinct React component categories which have dramatically improved our React codes maintainability, readability, testability, and scalability.
In this series of articles we will (1) talk about some basic principles of software and React architecture, (2) share our implementation of these principles as three categories of react components, (3) and finally share how this has leveled up our React code at FullStory.
Back to the basics
So before we get into how we architect our app at FullStory, let’s revisit some basic principles of software engineering and React.
Single Responsibility
The principle of single responsibility states “every class in a computer program should have responsibility over a single part of that program's functionality, which it should encapsulate. All of that module, class or function's services should be narrowly aligned with that responsibility.” [wikipedia]
If we convert this to React terminology we would say that every component should have a single responsibility. React components that have a single responsibility are much easier to reason about and change. A chief benefit of the single responsibility principle is the scalability of a thing that has a single responsibility. If that principle is adhered to, we would only change our component when the requirements for its one responsibility changes - otherwise the requirement must be met using a different component or tool.
Let’s look at an example of a component called StarWarsCharacters
that displays a list of Star Wars characters and renders a form to create new ones.
Note: The component above is not a usable and complete component but it serves the purpose for this example.
This component is very similar to many of the components we have in our codebase here at FullStory and maybe some of the ones in yours! Let’s see if this component fits into the single responsibility principle by listing out its responsibilities.
It is responsible for:
Accessing data from the redux store (Line 14 - 15)
Managing state of the create character form (Line 17 - 21)
Handling form submit (Line 23 - 26)
Dispatching the request action for fetching Star Wars characters (Line 28)
Rendering a loading state (Line 30)
Rendering and styling the list of characters to the UI (Line 34 - 40)
Creating a Route for the create character form (Line 44)
Rendering the create modal and form (Line 47 - 79)
Wow. That’s a lot of responsibilities - 8 to be exact. So, no this component does not have a single responsibility or reason to change. Some of its responsibilities might narrowly fit into a single responsibility, but some of them definitely do not. It doesn’t seem quite right that, if we have a new requirement for some validation of the character form, we risk breaking the rendering or fetching of Star Wars characters.
There are some good practices in this component and some separation of responsibilities. To name a few: we’re using a Modal
component that we can expect to handle “modal-ly” things, we’re passing off fetching logic and state management to redux, it’s not using class-based components, and is using hooks, but we can do even better!
Stay tuned to learn more about how we can address this in part two of this series.
Composition
The second principle we keep in mind when architecting our React apps is the principle of composition. The principle of composition is a core feature of React and its component architecture. Composition is the name given to the arrangement of components of varying complexity and specificity. A component that adheres to the principle of composition is said to be "composable." A composable component is one that is easily used inside of other components.
At FullStory we have an internal component library that is similar to popular community UI libraries like Material UI or Evergreen. In our component library we have a lot of components of varying specificity. Some have more specific purposes like the Button
. The Button
is pretty straight forward. It provides styling and functionality that you would expect when using a button and is widely used across other components that require button functionality. Another example in our component library is our Select
dropdown component. Its purpose, again quite straight forward, is to provide the functionality of a button that opens a menu of selectable options. The Select
component uses the Button
component internally and is therefore utilizing composition. See the small example below for a visual representation of this.
Returning to the StarWarsCharacters
component example we can see that it uses composition in a few places, namely with the Modal
and Route
components. But, we can do better! You can probably easily spot a few places that this component could be broken into smaller and more specific components. We'll revisit this how we can better utilize composition in part 2 of this series. So stay tuned!
Testability
The last principle we focused on was testability. At FullStory we love writing tests because it makes us more confident that we won’t break things as we make changes. Therefore, the testability of our React components indirectly correlates to the confidence we have by making tests much easier to write.
Let’s see if our `StarWarsCharacters` component is testable by determining how easy it would be to test that the user cannot submit the create a character form unless they give the character a name. Looking at the component it looks like we’re going to have to do a lot of work in order to write this simple test.
We would need to:
Mock redux
Mock react router
Mock api requests that are an internal implementation detail of the redux fetch actions
Click the create button
Then we can finally write our form validation test. That’s a lot of work to test something that is completely isolated to form functionality. Say it with me - we can do better!
Conclusion
So we’ve discussed the principles of architecture that we want to keep in mind as we build our React components. We’ve seen an example that is probably similar to react components you might have seen before and how it breaks the principles we’ve discussed here. And we know we can do better! So, stay tuned for part 2 of this series where we will implement these principles to level up our StarWarsCharacter
component so that it is composed of components that have a single responsibility and are highly testable.