At FullStory, we believe in strong opinions that are loosely held. If we’re not careful, it’s easy to stop short and just have “strong opinions.” But ensuring that those strong opinions are “loosely held” creates a greater ability for us to change, grow, and learn. In this final article of the series on how we architect our React apps at FullStory, we will see how loosely holding our strong opinions about React architecture enables us to embrace the benefits and iterate on the downsides.
But first, let’s do a quick review. In Part Two of this series we proposed three categories of React components that our React apps should be composed of:
Views are responsible for defining the layout and routing of a page or section of a page
Containers are responsible for passing data (possibly fetched asynchronously) or state to its children
Components are responsible for rendering the UI.
Part Two also mentions that containers and components aren’t new concepts—they’ve actually been around for a while now. However, as React has changed, these concepts have grown controversial. So, let’s dive in and take a look at the pros and cons to architecting our app using the three categories of components.
Pros of component categories
In Part One of the series, we talked about how important the testability of a React component is. We gave an example of a React component that was difficult to test. Without our component categories, it’s easy to get into a state where you have to mock a lot of things before you can actually test logical sections of your component. This can lead to complex and brittle tests.
When we use the single responsibility principle and apply our component categories, each type of component category maps well to a specific type of test. Since our components render UI as a function of their props, we can very easily write unit tests to verify our user’s experience without having to mock any providers. At the view and container level we can then write meaningful integration tests to verify that larger parts of the app work as expected.
In the gist below we are testing a component, the
CharacterCreateForm, and a container, the
CharacterListContainer. The component test could be considered a unit test. It’s testing a small piece of functionality very specific to the UX we expect, namely that
onSubmit is not called unless a name is provided. On the other hand, the container test can then focus on testing the integration between the API layer code and the UI components it renders. If we tried to write similar tests using the example component from Part One of this series, our tests would be less focused and more brittle.
Check out the links below for more examples.
Mental debugging flow chart
One of the key benefits of this architecture is how the process of elimination makes finding bugs in our code much easier. If we find a bug, it’s usually pretty easy to answer the question, “Is this a UI bug, is this a bug with how we’re fetching or manipulating data, or is this a bug with the layout or routing of the page?” By using this mental debugging flow chart we can easily eliminate large sections of our code that we know probably aren’t related, which allows us to narrow down where that bug might live.
At FullStory, several different teams work on different verticals of our React application. One of our company values is that we “win (or lose) together,” so it’s not uncommon to dive into code in a vertical that isn’t technically owned by your team. Having a consistent and clearly defined architecture allows engineers to easily jump from one vertical to another with a sense of familiarity and understanding.
Fosters individual creativity
As engineers, we often express our internal uniqueness and creativity in the ways we solve problems through writing code. We can look at the code we write as our own unique mental fingerprint. Using our component categories allows us to benefit from code consistency across our organization at a macro level, and fosters individuality and creativity at the micro level. This means that anyone writing react code can easily move from team to team and understand generally how the code is structured, and at the same time feel the freedom to write their component, container, view, hook, etc. while fostering their individual creative freedom at that level.
Foundation for future React patterns
Finally, this architecture was designed to consider and support future React patterns. Consider code splitting and data fetching. We can easily lazily load our container components and provide meaningful loading UI states for these async actions. This also prepares us for logical points to use Suspense when React is ready for that.
Cons of component categories
The main argument against grouping components by category is that it lacks scalability. This can be true without adding further context. Over time, as your app grows in complexity, simply splitting components into categories like this without further categorization does break down. Avoiding naming collisions and finding components becomes difficult as the application grows. At FullStory we have thousands of React components. So, this is a problem we certainly would have faced by now if we only had a few directories for these different categories of components. However, this is not the way our application is broken down.
Over time, an application, vertical, or feature can become complex enough that, no matter how well you plan, these kinds of problems can arise. So, that’s just a fact that we accept and expect. For now, in our monorepo, we have packages that correspond to different verticals of our application. If that vertical is complex enough, we might create another directory in that package that might have sub-verticals. When that breaks down, we then split the sub-verticals out into their own packages.
Our FullStory settings “page” is a great example of this. It’s a single vertical of our application, technically, but it is composed of many rather complex pages. Below is an example of what our directory structure looks like.
settings directory is a local private npm module in our monorepo that contains all of the code responsible for rendering the settings UI. Inside of that directory we have a
domains directory that contains the sub pages in our settings app. In this example we have the
users directories which correspond to our
users settings pages. Our directory structure in these domain directories looks exactly like the Settings directory does. Let’s say we get to the point where the
users page begins to become complex enough to warrant its own
domains directory—at that time it probably makes sense to break it off into its own package, which would actually be quite easy given that it’s largely self-contained.
In short, grouping React code by feature first and component category second can help prepare for the inevitable change of our React apps.
Imperfect component boundaries
Sometimes our UI can be so complex that we reach a point where it seems like our component categories fail to support what we actually need to build. I like to approach this problem in two stages.
First, while it may seem like a “cheap” answer, I “think harder.” Over time, as I’ve grown to appreciate the benefits of these categories, my trust in the categories has grown too. Sometimes composing your components in new ways can create cleaner boundaries and isolate responsibilities—even if at first it doesn’t seem possible.
In fairness, “think harder” isn’t always the answer. So secondly, in certain cases these boundaries can fail to support our needs or lead to unnecessarily complex component trees or prop interfaces. If this happens, the best thing to do is throw away everything you’ve built and start from scratch. No! Just kidding of course. It’s 100% acceptable to break the “rules” that aren’t really “rules,” after all. Since we only hold loosely to our strong opinions, it’s ok to do things differently when necessary. We do still want to write consistently architected code, but sometimes we do have to break from that. If we still adhere to the principle of single responsibility and write composable and testable React components, though, we can still have many of the benefits we have talked about
I’ve seen some arguments that hooks make component categories like views, containers, and components unnecessary. However, hooks themselves can also fall into the categories of routing/layout, async actions, or UI. At FullStory, we still find it useful to separate concerns, like async actions, routing, or UI, into these component categories even if the logic is contained in hooks.
For example, some of our internal component library components (pure UI components) use hooks to share UI functionality like keyboard accessibility. This still falls under the category of UI. In our containers, we often have hooks that fetch data and return async state properties which our containers then use to conditionally render UI (loading spinners, skeleton states, or actual components if the data is ready). Hooks, in our opinion, don’t make these boundaries unnecessary; they’re a completely different tool in our tool belt that improve our developer experience in different ways.
There are two things I hope you take away from this series of articles. First, by separating our React components into distinct categories, we truly can improve the scalability, testability, and readability of our React apps. Secondly, by loosely holding to these strong opinions we can iterate and ultimately “suck less.”
If you’ve enjoyed this series on how we write React code at FullStory and love solving complex engineering problems, come join our team! We’re hiring.