I have been working with React over a couple of years now, and over those years, I have noticed that there is a development life cycle ♻️ that tends to repeat itself for each component. We create a simple component on a single file, with no more than 30 - 40 lines of code where you define how it works, its internal state and how it is rendered to the end user. You also create a test file, where you test the logic that you implemented, passing different props and seeing how your component behaves.
Up until now, everything looks great! Your component is reusable, has tests of its own and it takes care of a simple task. But then, a new feature arrives and we need to add some code to it. We add those changes, we adapt our styles and tests to match them, and voilà! Our component displays this new cool feature 😎😎 and we still have it encapsulated in a single file within a single component.
However, we know that it does not end there. New features arrive all the time, and a component that used to be 40 lines of code becomes a really complex file with over 500 lines of code taking care of multiple responsibilities, and our tests and stylesheets start to suffer from the same symptom. I call this the snow component effect ❄️❄️, where we keep on adding code and features to a component, and it then becomes this monster that haunts us throughout our releases 👻👻
So how can we minimize this effect? What things do we need to keep an eye on in order to detect these scenarios? And how can we refactor our code to keep the snow component effect at bay?
I decided that it would be best to take a look at these scenarios through a series of code examples that illustrate how a simple component can escalate real quick and what code smells arise that tell us "hey, maybe we can move this to another component!". These code smells 👃👃 do not mean that you are doing something wrong, they are just pointers that can help you to figure out if what you are doing makes sense for your current scenario, and maybe detect that there is a better approach! The idea behind all of this is that the code makes sense for you and your team, and that the decisions you are taking will not tie you up in the future.
Therefore, I created a small web that displays the latest PS4 games, showing a title, an image and a link to the game itself:
Taking a quick glance at the component, we could say that it looks simple enough right 🤔🤔? Our component should expect a list of games, each of them with a title, a source for the image and the link to redirect, and we will iterate through those games and create the link tag and the image tag for each of them:
As simple as it looks, we can already identify a couple of things that should raise some questions regarding our logic:
We are passing a games list to our component, so in order to check that each game has the correct information, we created a prop type validation that checks for the prop games to be an array with a specific shape, which is already starting to look like a small snow ball. Does it make sense to validate the game's information on the component that renders a list of them? Should the component that renders a list of games validate the information of each game, or should it only take care of how a list of games is rendered, no matter what the game has?
What if we want to render these games in a different manner, maybe using ordered lists or just plain divs? Is the rendering of a specific game tied to the fact that there is a list of them? What if we want to render a single game in a non-list context?
We have created two base class names for our components, one called "games" which we are using for all of our elements that are related to the list of games, and "game" for everything related to the game itself, and we are using one or the other depending on the element. This is already showing us that styles and class names have a different base depending on what we are rendering. Does it help us to have these class definitions on the same file? What about stylesheets? Does it make sense to have styles for "games" elements and "game" element on the same file?
Now imagine that we released this component as is, and a couple of weeks later new requirements arrive. Winter is coming for your components:
- We want to add some visual distinction to the games that we consider featured, maybe a border outline of some sort, just to capture the user's attention towards these games
- We also want to show what games are not available, maybe due to out-of-stock situations, and we want to prevent users from clicking on it.
From a data standpoint, it makes sense to add some flags to each game, maybe something like isFeatured and isAvailable. But what happens to our component? To tackle these requirements, we will add specific css classes to our component in case the game is featured or available, and also prevent the click on the link if it is not available.
This new requirement has added some key changes that should also start a discussion:
We added the prop types for isAvailable and isFeatured on the arrayOf definition. But these are new props, and it could take sometime for the consumers of this component to adjust to these new props. So in order to avoid showing Prop Type warnings as well as resulting in unexpected behavior, we want to default to a specific value. Since these are booleans, it should be easy enough, so let's say we will default to isFeatured = false and isAvailable = true. But taking a look at the example, how can we actually achieve that? We currently have an arrayOf a shape, how are we going to write our default props? Should default props for an object depend on the data structure that holds it? We could achieve that by creating a specific default function for these props, but we would be mutating the array, which can result in unstable behavior.
Changes related to these features are now directly tied to the component that renders the list of games. If sometime in the future, these css classes change, or we need to add a new variety, we will always comeback to the games component. Here we could ask ourselves, does it make sense for functionality related to iterable objects live beside the component that iterates them?
Also let's take a look at the test for this component. We will need to define different arrays in order to try out the original requirement plus how the game is rendered when it is featured and when it is available:
- Should the games component really care if the games list has some featured games and some available ones? Should this component test edge cases that are related to the iterated objects? Aren't they more tests cases related to the game itself? If we want to test the click on the link and see that it trigger the preventDefault function when the game is not available, does it make sense to test that on the Games component test?
Up until now, we have seen that there are a couple of decisions that we made make the Games Component and each game itself really coupled between one another. But what if we separate these pieces, and create another component for the Game itself?
So we have created two separate components, one called Games and another one called Game. By doing so, we have several advantages that come from it:
The most noticeable is that the Games component has turned into a much smaller and simpler component. It is easier now to read and maintain, and tests will only need to pass and test scenarios related to the games prop and the title prop. No need to create edge cases for the game specific functionality.
Namespaces used for classes are now separated between the two components, making our classes independent from each other. Separating these components also allow us also separate our styles, maybe creating separate files for each component.
Game props are directly validated on the Game component, not in the Games component. The Game component is actually the one that states what things are necessary for it to render, and are not tied to the data structure that holds them. Also, we can declare those default props for isAvailable and isFeatured, setting the default behavior directly in the Game component.
We have also made the Game component independent from the context in which it renders. Previously, there was no way to render an image outside of a list. Now, we can use the Game component wherever we want, in a list, individually, or in another way. When separating components, we can extract the functionality and increase our reusability.
Finally, testing has become much simpler and targeted, since for example, testing related to the featured and availability functionalities are directly tested on the Game component, and the Games component does not know about this. In the future, if there are further changes to the game component, we will add the appropriate tests to that suite, and our Games component will still be agnostic to this.
There is definitely a breaking point when developing React components when our components pass from a maintainable and reusable piece of code to a big complex component that englobes a lot of functionality. This at first can seem simple, but later in the development process, iterations and new functionality can turn these components into a snow ball, each time adding more and more code that becomes this massive structure that everyone is scared of getting hit by it.
In order to avoid that, take sometime to think through where each functionality should live, and if it makes sense for it to live in a particular component, or maybe migrating to another one will save you sometime in the future. Try to see what helps your code be more maintainable and readable, and I hope that these pointers I found can give you an idea of what questions to ask when you are writing React components :)