Content is crucial to any React web application. It makes our applications interactive for users and makes them a web application over just a static website. For bigger React applications, it's not uncommon to have ten to a hundred different content streams throughout. Because of this sheer volume, it's important to implement them properly.
Every content stream has different states. The most common separation has 4 different categories, namely when the stream is pending, loading, successfully loaded, or has errored. This means that every component has to implement 4 different code branches per content stream to account for every possible state. On top of that, every additional content stream contributes multiplicatively towards the number of branches that you need to maintain in the code.
Every possible branch leads to additional logic to account for that branch in the code, which in turn increases the complexity of the React code. As the complexity rises, it becomes more and more difficult to keep the code readable. This will lead to worse maintainability, which can be a serious risk long-term for any React codebase. Therefore, it's very important to make sure that the code for handling React content states stays readable, starting at the most fundamental level.
This article will go over the two most common ways to handle content states in your React components. We will discuss the advantages and drawbacks in terms of readability and the use cases for every structure. This information will provide you with a solid foundation on how to implement content states in your React components in a readable manner. After this article, you will be able to apply these structures, identify when your code declines in readability, and keep more complex constructions readable by building on top of this knowledge.
The most common approach you will encounter is handling the content states directly in the render through conditionals. What you do is check for a specific content state and, based on it, conditionally render code that reflects the UI for that content state. Generally, it would look as follows:
export const ComponentWithContent = (props) => {
// Code...
return (
<div className="container">
{contentState === "pending" && <span>Pending...</span>}
{contentState === "loading" && <span>Loading...</span>}
{contentState === "error" && <span>An error has occurred...</span>}
{contentState === "success" && <div>{/* ... */}</div>}
</div>
);
}
Here we have a component with a variable that captures the state of a content stream. The stream could be coming from anywhere: props, state, a hook, or external code. In the context of this article, this is all considered the same and does not affect anything that will be discussed. The most important aspect is that there is a variable that captures the content state.
In the render, we check for the different possible content states and render UI based on it. In this example, we make use of the AND operator. But all the same, it would apply even if the conditionals were implemented differently. For example, using ternary operators or composite components that handle the state.
export const ComponentWithContent = (props) => {
// Code...
return (
<div>
<State value={contentState}>
<State.Pending>
<span>Pending...</span>
</State.Pending>
<State.Loading>
<span>Loading...</span>
</State.Loading>
<State.Error>
<span>An error has occurred...</span>
</State.Error>
<State.Success>
<div>{/* ... */}</div>
</State.Success>
</State>
</div>
);
}
The biggest advantage of handling all the cases of the content stream in the render is that everything is exactly in one place. When reviewing, going through the code, or refactoring it, you only have to look at one place. You will immediately get an overview of the entire structure and see how the content states are handled.
Another advantage is that the similarities and differences are clear. In particular, this structure focuses on similarities while highlighting minor differences. Based on where the conditionals for the content states are placed, it's relatively easy to determine what code is shared and what code is specific for a certain state. Not only does this improve the readability, but also the future maintainability, as this is crucial information to have when refactoring such a component in the future without prior context.
Because of the way this structure focuses on similarities and highlights differences, it works great in scenarios where the different content states have either similar DOM structures or only affect similar areas of the DOM. In those cases, the different branches are grouped at the location that they target in the render function. If you are reading through React code from top to bottom, this will feel very natural as the last section is always the render and greatly improve readability.
Take the example at the start of this section. All of the branches are nested inside the container element. While reading, refactoring, or reviewing this code, two things are immediately clear. First is that the UI for all the content states is the same up to and including the container element. The second is that the content only affects the UI in this particular area, the children of the container element.
In the context of this trimmed-down example, these nuggets of information are not too significant. But in real-world scenarios, DOM structures are usually significantly larger. Navigating your way through them is not a trivial task, let alone being able to identify similarities and differences, which is important for refactoring and maintainability. In those cases, every bit of information adds up, and handling all content states in the render is one way to improve readability.
While we have discussed the advantages and use cases, there are also scenarios where this approach will actually hurt the readability more than it does good. As mentioned, this approach works great if the different content states have similar DOM structures or only affect similar areas of the DOM.
If these do not apply to the component, then implementing the content states using this approach can become quite a mess. If a lot of different areas of the DOM are affected by different content states, this approach will result in a lot of distributed conditionals inside your render. While at a low number, this isn't too bad. The readability of your React code will greatly decrease as the number of conditionals increases because they are relatively verbose.
This is even worse if the content states have varying DOM structures. Trying to create one large structure that will accommodate all of them rarely does anything good for the readability of the code. It will split up your code into even larger conditional blocks and distribute them over different locations and even nesting levels. This will result in an extremely convoluted and hard-to-follow DOM structure, which will only hurt the code readability.
Another approach to handle content states is through early returns. This approach puts the conditionals out of render and moves them up in the component. When the condition is met, the component does an early return with the appropriate code. This continues until all the content branches are handled, and all the options are exhausted. Generally, it would look as follows:
export const ComponentWithContent = (props) => {
// Code...
if (contentState === "pending") {
return (
<SomePendingComponent />
);
}
if (contentState === "loading") {
return (
<LoadingSpinner />
);
}
if (contentState === "error") {
return (
<ErrorMessage>An error has occurred...</ErrorMessage>
);
}
return <div>{/* ... */}</div>;
};
In the example, the component first checks whether the content stream is still pending. If so, it will do an early return with a component that is specific to the pending state. If not, we will continue and immediately check for the next possible state. The same goes for the loading state and then the error state. Lastly, we are sure that all the other options were already exhausted, so the last case to handle is the success state, which we can do through a regular return.
The biggest advantage of this approach is that this structure requires the least effort of keeping track of the data flows when reading through the component code top to bottom. The code is always only tackling one state at a time. This means that when you read it, you only have to remember which state you are in, which is indicated by the conditionals. Then, when you enter the block statement, you know that everything inside of the block is only related to that particular content state. This decreases the burden on the reader to constantly have to keep a mental modal of the UI, the similarities between states, and the differences. Rather, they can focus on a single state at a time, like reading chapters of a book, and move on to the next state when they are done.
In line with this is how people most commonly prefer to go through the different content states. Based on what I personally do and saw from other people, we most of the time prefer to first handle the loading states, then the error one, and then leave the success state for last. This approach fits exactly in that preference and thus matches the structure of the code the most with the expectations of readers. This will make the code more natural to follow and to read, thus benefitting the readability.
This approach works really great if the different content states lead to totally different DOM structures. If similarities are small, then it's becomes very difficult to both maintain the readability and keep the code together while still accounting for all the differences because there are a lot. So instead, the content cases are separated from each other and handled on their own. This puts most of the emphasis on the differences. The more different the DOM structures for the content states are, the more this approach enhances the readability of the code.
The best-case scenario for this approach is that every content state has a totally different DOM structure, as that maximizes the readability of this approach. But that is not always possible or applicable in real-world scenarios. Likely, there will still be some similarities in structure between content states, which is also the main drawback to this approach.
In general, handling content states through early returns does really well to accommodate for differences but is very bad at accounting for similarities. Because of the way it tackles content states one entirely at a time, code will have to be duplicated if similarities occur. The more code is shared between the content states, the more code duplication it introduces to the React component.
Another drawback of this approach is that the code and logic for handling the content stream are distributed vertically all over the component. It's impossible to get a quick overview of how all the different content states are handled. Instead, if the readers need a complete picture, e.g., refactoring, they are required to go through all of its tops to bottom and compare them case by case. This can take quite some time and effort.
Another drawback is the distance that is created between the code for handling a certain case and the utility code related to it. The usual structure of React components is that hooks reside at the top. Not only is this a convention, but also a requirement as they can't be conditionally called. In this approach, we're actively creating distance between that code and code for states that are handled later in the component. The later a state is handled and the larger the code for handling the other states are, the more distance is created relative to relevant (state) variables, callbacks, or hooks. In certain scenarios, the distance can become so big that it actively obstructs how efficiently the reader can go through the code and understand it, thus diminishing the readability.
Content streams are an important part of any React project. They make React applications live up and interactive for the users. But from the development perspective, your components become complex very quickly as the number of content streams increases. This leads to a multiplicative increase in the number of content states that the components need to handle. Long term, making sure that this code is readable has a serious impact on maintainability.
In this article, we discussed two fundamental approaches to handling content states in your React components. Either handling them in the render, using conditionals or composite components, or handling them through early returns. Both have their advantages, drawbacks, and use cases in terms of readability. This information provides you with a solid foundation on how to implement content states in your React components in a readable manner. You will be able to apply these structures, identify when your code declines in readability, and keep more complex constructions readable by building on top of this knowledge.
Also published on https://www.chakshunyu.com/blog/how-to-write-readable-react-content-states/.