36 min read

The Ultimate Guide to React Component Readability and State Clean Code

Being able to show UI elements conditionally to users is an essential part of making any React application interactive and more than just a static website. Based on different user interactions, content streams, and application states, your React application will have to render different elements to the user. One of the ways to implement this is through inline conditional rendering, which is a common practice in React development. But with how essential this type of code is, have you ever considered just how readable your inline conditional rendering code actually is?

Making sure that your code is highly readable is important in a lot of different ways. It makes the lives of other developers easier when they have to read, review, or maintain your code in the future. Being able to deliver high-quality readable code directly saves an engineering team time and effort at different moments, and thus holds tremendous implicit value.

A developer will make difficult code work, but a good developer will make difficult code look easy.

The importance of readable code is especially applicable to code patterns that occur frequently in your codebase as the impact is felt tenfold, like inline conditional rendering for React projects. In this section, we will go over the two most common approaches to implement inline conditional rendering: using the AND operator and the ternary operator.

We will discuss the advantages and drawbacks in terms of readability, and the use cases for every approach. This information will provide you with a solid foundation on how to implement inline conditional rendering in your React components in a readable manner. After this, you will be able to apply these approaches, identify when your code declines in readability, and keep more complex constructions readable by building on top of this knowledge.

Inline Conditional Rendering

Using The AND Operator

The most popular and most used approach to implementing inline conditional rendering is through the AND operator. For this, the AND operator is used to verify a condition. Then, we specify the behaviour for the if branch. Then, if the condition evaluates to true, the branch is triggered and an element is rendered. Generally, it would look as follows:

const Card = ({ imageUrl }) => {
  return (
    <div className="card-container">
      {imageUrl && <Thumbnail url={imageUrl} />}
      <CardBodySection />
    </div>
  );
};

The biggest advantage of using the AND operator for inline conditional rendering is that it doesn’t require a lot of code. It is very short and concise. This reason alone already helps with the code readability. The fewer code developers have to read through during refactoring and future maintenance, the fewer opportunities will occur in which they get confused by the code.

In line with being short and concise, the AND operator also keeps your code very compact. Because of its non-verbosity, using it will save space in both the horizontal and vertical directions. This will greatly help other developers when reviewing your code in merge requests. Reviewing is often done on platforms like Github and Gitlab, which means that they happen in the browser. In the browser, there is barely any IDE support and space is limited. Being able to keep your code compact makes it easier for others to go through your code on these platforms and thus benefits the readability.

Logic wise, the biggest advantage is that the AND operator only describes one case. Nothing more, nothing less. In their mental modal, developers only have to account for the condition and the if branch while reading through the code. Using the AND operator gives an implicit signal that there is no else branch.

A major drawback to using the AND operator for inline conditional rendering is the fact that it relies on short-circuiting, an implicit JavaScript behaviour. When JavaScript evaluates an AND expression and the first operand evaluates the false, then the result of the first operand is returned and the evaluation of the second operand is entirely skipped. This concept is called short-circuit and it is crucial knowledge to understand how inline conditional rendering with the AND operator works.

This requirement heavily affects the maintainability and readability of this approach, as there is always the implicit assumption that the reader already understands this concept. Despite how likely this is in the context of React development, it’s not a guaranteed given. In cases where someone isn’t aware of short-circuiting, the caused confusion when reading the code can be significant.

The biggest drawback of using this approach lies in how the AND operator communicates to React what it should render. If the condition holds, then the second operand is treated as the if branch and used as the return value from the AND expression. This value is then used and rendered by React. In the context of inline conditional rendering, the second operand will be an element as that is what React expects.

But there will be cases where the condition doesn’t hold. In those cases, the resulting value of the condition (the first operand) is returned to React because of how short-circuiting works. This is fine because React understands that certain values should not be actually rendered to the DOM. These are falsy values like undefined, null, false. React will ignore these values and skip rendering them altogether.

The problem is that not all falsy values will be ignored by React, which can lead to unexpected rendering behaviour when the user is not aware of it. An example is the integer value 0, which is a falsy value that will be rendered by React as an actual value. A common pitfall for this is when checking for the length of an array.

// The AND operator below will return `0` if the tags array is empty,
// which will result into "0" being rendered on the screen.
const Card = ({ tags }) => {
  return (
    <div className="card-container">
      <CardBodySection />
      {tags.length && <CardTagsSection tags={tags} />}
    </div>
  );
};

This unexpected behaviour can cause a lot of confusion if the developer isn’t aware of the concept of short-circuiting and how React handles falsy values. This unexpected issue is something you always have to be aware of when encountering inline conditional rendering through AND operators, which greatly decreases the readability of this approach.

Lastly, a situational drawback that is often overlooked to using the AND operator is related to the required effort to read conditional expressions. Processing conditional expressions is not a trivial task and extending them in any way only makes it more difficult. This also applies from a readability perspective.

Using the AND operator to handle inline conditional rendering makes the implementation very easy. If you need the opposite case of a conditional, then you can just flip it with the NOT operator. This is also what happens very often, but this does exactly what we just discussed. Namely, adding another layer on top of the existing condition and thus decreasing the readability. Although it can be considered a minor drawback, the effect on readability can add up in larger numbers.

Summary

  • ✅ Short and concise.
  • ✅ Keeps things compact.
  • ✅ Great a describing one case.
  • ⛔ Relies on implicit JavaSript behaviour called short-circuiting.
  • ⛔ Can lead to unexpected issues when rendering certain falsy values in React.
  • ⛔ Handling the opposite case requires flipping the conditional.

Using The Ternary Operator

An alternative approach that is often used for inline conditional rendering is making use of the ternary operator. In this approach, the ternary operator is used to verify a condition. Inside the ternary, you define the behaviour for both the if and else branch. Then, based on the result of the condition, the appropriate branch is triggered to return an element to render. Generally, it would look as follows:

const Card = ({ imageUrl }) => {
  return (
    <div className="card-container">
      {imageUrl ? <Thumbnail url={imageUrl} /> : null}
      <CardBodySection />
    </div>
  );
};

The biggest advantage in terms of readability to using the ternary operator for inline conditional rendering is its explicitness. When using the ternary operator, you always have to specify both the if and the else branch. This means that for readers of your code, it’s always explicitly clear what is expected from the conditional rendering code.

While people will commonly think about the scenario where both branches return something to render, it equally holds for the scenario where only one branch is relevant. Just like in the above example with the else branch, using the ternary operator makes it explicitly clear to the reader that nothing is expected in one of the two branches. This degree of explicitness for both cases reduces ambiguity and confusion, thus benefitting the readability.

Another benefit to the ternary operator is that you can always use the same logic structure. A ternary expression always starts with the condition, followed by the if branch, and lastly the else branch. Because it always specifies both cases, there’s never a need to flip conditionals. This means that the order and structure of the ternary expression are always the same, and no additional logic layer is added on top of the existing condition. When other developers go through your React code and encounter a conditional render, they know it always reads in the same, most natural way. This makes it easier for them to get through your code.

The last benefit is related to handling different branches of the same condition at different places. Sometimes, the different branches of an inline conditional render are not in the same DOM location. Take the following scenario as an example:

const ComponentWithIconPlacement = ({ renderIconLeft }) => {
  return (
    <div className="container">
      {renderIconLeft ? <Icon /> : null}
      {someContent}
      {renderIconLeft ? null : <Icon />}
    </div>
  );
};

Here we have a component that accepts a boolean renderIconLeft prop, renders some content, and renders an icon either left or right from the content based on the mentioned prop. Based on the value of renderIconLeft, the icon has to be rendered at a different place in the DOM.

Using the ternary operator makes it easier to spot opposite cases that are related to each other. Due to fact that the logic structure can always remain the same and that the conditions don’t have to be flipped, the conditional rendering expressions are very similar and thus easy to match with its counterpart.

Based on the above example: after initially reading the first ternary operator, you might be left wondering what the expected behaviour is when renderIconLeft does not apply. Without having to flip conditions or look for related statements, you only have to look for ternary operators with the same condition but handle the opposite case. In this case, that is handled immediately after the content. Because the same logical expression is used, it’s very easy to find and match them, and determine what the behaviour is for the opposite case.

The main drawback to using the ternary operator for inline conditional rendering is its verbosity. By default, it requires a lot more code, which means that developers have to read and try to understand more code. This introduces more opportunities that can cause confusion, which makes it harder for developers to read through the code. This gets even worse when the ternary expressions become more complex like when larger conditions are necessary, the branches require more code, or ternary operators are nested. Combining these factors will have a significant compounding effect on the readability of your code.

Another drawback to using the ternary operator is again related to its verbosity. Because of it, a ternary operator can take up a lot of space both horizontally and vertically. How much vertical and horizontal space a ternary expression takes up directly affects the readability of the code. This means that the readability of ternary expressions can highly fluctuate dependent on the reader’s screen width, text wrapping configuration, and formatter print width. While this is not an unsolvable issue, having this dependency is suboptimal and requires time and effort to be addressed.

// The following two components are exactly the same, but have a totally
// different level of readability due to formatting.

// 1. One-liner with larger print width.
const Card = ({ imageUrl }) => {
  return (
    <div className="card-container">
      {imageUrl ? (
        <Thumbnail
          url={imageUrl}
          rounded
          placeholder={false}
          {...someMoreProps}
        />
      ) : null}
      <CardBodySection />
    </div>
  );
};

// 2. Multi-liner with smaller print width like a lot of default settings.
const Card = ({ imageUrl }) => {
  return (
    <div className="card-container">
      {imageUrl ? (
        <Thumbnail
          url={imageUrl}
          rounded
          placeholder={false}
          {...someMoreProps}
        />
      ) : null}
      <CardBodySection />
    </div>
  );
};

Summary

  • ✅ Explicit for both the if and else case.
  • ✅ Can always use the same logic structure (condition-if-else) without flipping conditions.
  • ✅ Easier to spot the opposite case if it’s located somewhere else.
  • ⛔ Very verbose, especially in more complex cases.
  • ⛔ Readability is affected by screen width and the formatter print width.

React Readability Analysis Of Implementing Custom Hooks

The introduction of hooks in React 16.8 has totally changed the way we implement logic in our React components. React hooks provided us with an entirely new way of encapsulating logic, sharing it between components, and keeping it separate from the view layer. The key to all of this is the fact that we can create our own custom hooks.

Before the introduction of React hooks, implementing logic in React components meant you had to use class components. For two years, this was exactly my situation. I worked at a company where the frontend team was still working with class components, even after React hooks were released. While there was nothing inherently wrong with this situation, the experience was suboptimal.

Implementing logic in React class components was messy, verbose, distributed, and extremely unorganised. Most of the time, it also didn’t allow for easy sharing between components. This was similar to what the React community experienced, which eventually lead to the creation of hooks. Using React hooks, implementing logic became structured, centralised, organised, and easily reusable across components.

One of the key reasons for this is the fact that React developers can encapsulate (small) pieces of logic into their own custom hooks. Nowadays, it’s a common practice in React development. But exactly because it’s so common, have you ever considered just how readable your way of creating custom hooks is?

In this section, we will go over three different approaches to implement custom hooks: not abstracting anything, exposing a limited set of behaviour through an API, and putting everything in a custom hook. We will discuss the advantages and drawbacks in terms of readability, and the use cases for every approach.

Don’t Abstract Anything

One of the most straightforward approaches to implementing custom hooks is actually not creating them in the first place. We don’t abstract anything away and, instead, leave all the logic, utility, and hooks-related code are inline in the component. Generally, it could look as follows:

const Component = () => {
  const [ids] = useArrayIds();

  const [storedIds, setStoredIds] = useState(ids);

  const removeId = useCallback((idToRemove) => {
    setStoredIds((oldIds) => oldIds.filter((oldId) => oldId !== idToRemove));
  }, []);

  useEffect(() => {
    setStoredIds(ids);
  }, [ids]);

  return (
    <div className="component-container">
      {storedIds.map((id) => (
        <button key={id} onClick={() => removeId(id)}>
          {/** Etc */}
        </button>
      ))}
    </div>
  );
};

The biggest advantage to implementing custom hooks by not abstracting anything is that all the relevant code is contained in a single location. When reviewing, going through the code, or refactoring it, the reader never needs to reach out to additional resources to understand the code. This in turn prevents a lot of file and line switching, which also avoids a lot of unnecessary distractions or losses of focus. This is extremely beneficial for the readability of your React code, especially when readers are reviewing your code in the browser where IDE support is still very limited.

Another advantage to this implementation is that all the code for both the logic and the UI are kept together in a single place. This is especially noticeable in small components. This means that the vertical distance between the logic and the UI code is kept small. This is very beneficial for the readability of your React code as readers can more easily connect the UI with their appropriate logic code, variables, and utility functions.

As mentioned, the benefits of not abstracting hook code into custom hooks are highlighted in smaller components. On the other side, the drawbacks to this approach are mainly felt in larger components that have to deal with multiple logic flows, data sources, or content flows.

As a component grows and gains more responsibilities, so will the number of logic and content flows that reside in the component. Keeping them inline instead of abstracting them into custom hooks means that all their code will live together. That’ll make all the different flows intertwined with one another. This in turn will cause the complexity of the code to become exponentially more complex as the number of logic and content flows in a component increases.

On the other side, there’s also the vertical distance between the code for logic and UI. When the component only has to deal with a single logic flow or trivial logic, the vertical distance is kept small, and the code for logic and UI are located close to each other. As mentioned before, this is beneficial for readability.

But in reality, this isn’t something that will always happen. Components will grow, so will the number of logic flows as we just mentioned and so will the complexity of individual flows. In all cases, the result is that more code will be introduced to components to handle the additional logic. In turn, this will increase the vertical distance between code for the logic and UI, and distribute code for different flows vertically all over the component.

This makes it impossible to get a quick overview of a single logic flow or to easily connect the code for the logic with the UI. Instead, if in any scenario the readers want to get a complete picture of the code, they are required to skim through all of the code from top to bottom and connect the different pieces accordingly. This can take quite some time and effort, which ultimately obstructs the readability of the code.

  • ✅ Everything is contained in a single location.
  • ✅ Great for keeping the logic and UI together in small components.
  • ⛔ The code becomes exponentially more complex as the number of logic flows in the component increase.
  • ⛔ The vertical distance between code handling the logic and the UI can become quite big, which ultimately obstructs the readability.

Expose Limited Behaviour As An API

A different approach to implementing the logic flow in a custom hook is by only exposing a limited amount of behaviour of the flow as an API. All the functions and active functionalities are abstracted into the custom hook.

Users of the flow will call the custom hook and only receive the necessary behaviour functions to implement the actual logic. This means that the custom hook is responsible for describing how the logic works, while the component is responsible for integrating the flow into its own lifecycle. Generally, this would look as follows:

const useIdsHandler = (ids) => {
  const [storedIds, setStoredIds] = useState(ids);

  const removeId = useCallback((idToRemove) => {
    setStoredIds((oldIds) => oldIds.filter((oldId) => oldId !== idToRemove));
  }, []);

  const resetIds = useCallback((newIds) => {
    setStoredIds(newIds);
  }, []);

  return { storedIds, removeId, resetIds };
};

const Component = () => {
  const [ids] = useArrayIds();

  const { storedIds, removeId, resetIds } = useIdsHandler(ids);

  useEffect(() => {
    resetIds(ids);
  }, [ids, resetIds]);

  return (
    <div className="component-container">
      {storedIds.map((id) => (
        <button key={id} onClick={() => removeId(id)}>
          {/** Etc */}
        </button>
      ))}
    </div>
  );
};

This approach is more like an intermediate solution, in between not abstracting anything (which we’ve just discussed) and putting everything inside a custom hook (which we’ll discuss afterwards). Because of this, the advantages to using this approach to implement custom hooks stand out less on their own. Instead, they’re more pronounced when considered together.

The first advantage of this approach is that it keeps the size of the component contained. Most of the code is abstracted in a custom hook, which means it’s moved away from the component. The only code remaining is the necessary behaviour. This significantly reduces the size of the component and makes it easier for the reader to keep an overview of the component.

Secondly, this approach attaches an explicit name to the behaviour and the logic. This is an often overlooked aspect when writing code. A common practice when writing more readable code is to extract values, functions, or conditionals into their own variables. This gives the respective code an explicit label describing what the code does. This approach does exactly that but for the behaviour exposed by the custom hook’s API. This helps the reader to understand the logic flow and thus has a positive impact on the readability of the code.

Lastly, this approach only abstracts the behaviour away into an API but leaves the usage of it to its users. This means that the components that use the custom hook are still the ones responsible for implementing the behaviour properly. When this is applied to multiple logic flows in a component, they are implemented closely to each other. This makes it easier for the reader to put different logic flows into the perspective of other flows and create a mental overview of all the interactions.

While being able to put multiple logic flows together inside the component can be an advantage, it can also be a drawback. Because they are all implemented in the component itself, the logic flows can become quite intertwined and difficult to separate. Factors that influence this are the number of logic flows, how often the different flows interact with each other, and how many of the different flows have to be executed in similar phases of the component’s lifecycle.

Depending on the combination of these factors, it can drastically change how easily a reader can go through the component’s code and understand everything. This makes it harder to gain an overview of all the logic flows, their interactions, connections, and interdependencies. Ultimately, this negatively affects the readability of the code.

Another drawback is that all the underlying details are hidden in the component. While having a custom hook in place is great for attaching a name and describing what the behaviour does, it doesn’t explain to the reader how it’s done. On top of that, the implementation of the logic is spread across multiple locations. The internals of the logic are located in the custom hook, while the usage is implemented in the component itself.

Situationally, this can have a significant negative impact on the readability of the code. In normal circumstances where the reader is only interested in the results and behaviour of the logic flow, this is not an issue. But when they want to dive into the specifics, details, integration into the component, and implementations, this distribution of code will cause more file, line, and code-switching. In those scenarios, this approach has a bigger negative impact on the readability of the React code compared to e.g. having all the code in one place.

  • ✅ Keeps the size of the component contained.
  • ✅ Attaches an explicit name to the behaviour of the logic and the logic itself.
  • ✅ Easier to put different logic flows into the perspective of other flows.
  • ⛔ More difficult to separate logic flows.
  • ⛔ Usage of the custom hook describes what the behaviour is, but not how the underlying logic works.
  • ⛔ Code for the logic flow is distributed across multiple locations.

Put Everything In A Custom Hook

Another approach to handling custom hooks is to literally put everything in a custom hook. This does exactly what it sounds like, namely that you take all of the code related to the logic flow and put it inside a custom hook. Then, you only return and expose the necessary logic, callbacks, or values from that custom hook for the component to use. Generally, that would look as follows:

const useIdsHandler = () => {
  const [ids] = useArrayIds();

  const [storedIds, setStoredIds] = useState(ids);

  useEffect(() => {
    setStoredIds(ids);
  }, [ids]);

  const removeId = useCallback((idToRemove) => {
    setStoredIds((oldIds) => oldIds.filter((oldId) => oldId !== idToRemove));
  }, []);

  return { storedIds, removeId };
};

const Component = () => {
  const { storedIds, removeId } = useIdsHandler();

  return (
    <div className="component-container">
      {storedIds.map((id) => (
        <button key={id} onClick={() => removeId(id)}>
          {/** Etc */}
        </button>
      ))}
    </div>
  );
};

The main advantage to implementing hooks logic by extracting everything into a custom hook is the amount of code. Because as much code as possible is abstracted away, it will result in as little remaining code as possible in the components themselves. This in turn will keep the component clean and will be beneficial to the readability of the code.

Another advantage to this approach is that every piece of logic is contained in one single place. Especially when dealing with multiple intertwined logic flows, this means that every flow is separated from one another. Sometimes when you’re going through the component’s code, you’re left wondering about the specific details of a certain single logic flow.

When implemented using this approach, you only have to navigate towards a single file or location to view everything related to that single logic flow. When trying to understand it, you’re not hindered or distracted by code from any other flow. This generally makes it easier to understand individual logic flows and therefore the component itself. In turn, this is tremendously beneficial for the readability of the React code.

The last advantage is related to the separation between code for the logic and the UI. When using custom hooks in such a way, the custom hook is responsible for everything related to the logic. As mentioned, the only things that it gives back are the necessary pieces for the UI to function properly. The API is minimal and provides a clear separation in responsibilities.

This clear separation between code for the logic and UI is very beneficial for the readability of the React code when readers are focused on understanding either part individually. It means that they can focus on either only the logic or only the UI, and don’t have to bother with the other. This reduces the amount of code that they have to keep track of, the cognitive load when reading through the code, and thus is positively beneficial for the readability.

One drawback to implementing logic flows by putting everything into a custom hook is that all the details are hidden from the component. When going through the component’s code, the reader will gain zero knowledge about the logic flow. This includes any behaviour, details, or side effects.

In a lot of cases, this can be perfectly acceptable. But for every detail that the reader wants to know about the logic flow, they will have to switch to the custom hook code. Making these switches between files or pieces of code actively obstruct the natural reading flow and can also introduce unnecessary distractions and losses of focus.

The biggest drawback of this approach is related to the previous drawback combined with its scalability. As mentioned, components are likely to contain more and more logic flows as the component itself scales. In certain scenarios, the logic flows will intertwine with one another in terms of their behaviour.

Understanding the connections, interactions, timings, and dependencies between logic flows can be crucial for understanding the component. But when all the logic flows are abstracted away in individual custom hooks, it becomes extremely difficult to create and keep track of a mental modal of all the different flows.

As a reader, on the surface, you’re only provided with the minimal amount of code that allows the component to make use of the logic. Understanding all the flows requires you to dive into the specifics of every custom hook, put them into perspective, and tying them all together. As the number of logic flows in a component grow, this can become an enormously difficult and time-consuming task, especially in the browser where IDE support is very limited.

  • ✅ Abstracts as much code as possible, resulting in as little code in the component as possible.
  • ✅ The code for every piece of logic is contained in one single place, separated from one another.
  • ✅ Clear separation between code for the logic and the UI.
  • ⛔ Hides all the details, including side effects, in the custom hook.
  • ⛔ Difficult to imagine how different logic flows interact with each other.

How To Write Readable React Content States

Content is crucial to any React web application. It is what makes our applications live up, interactive for users, and really what makes it 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.

In this section, we 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, 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.

Handle States In The Render

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 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 increase 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.

Summary

  • ✅ Everything is structured in one place.
  • ✅ Focuses on similarities and highlights differences.
  • ✅ Works great if content states have very similar DOM structures or affect the same area of the DOM.
  • ⛔ Will result in a lot of distributed conditionals in the render if content states have different DOM structures.
  • ⛔ Code can become a big mess where large blocks are separated conditionally and at different nesting levels.

Handle States Through Early Returns

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 match 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 it top 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.

Summary

  • ✅ Tackling one content state at a time.
  • ✅ Readers don’t have to keep a full mental modal of the similarities and differences, but can just focus on the current state that is handled.
  • ✅ Leaves the success case for last, which is the reading style a lot of developers prefer.
  • ✅ Works great if content states have very different DOM structures.
  • ⛔ Doesn’t handle code similarities well, which can quickly lead to a lot of code duplication.
  • ⛔ Impossible to get a quick overview of the content states logic.
  • ⛔ Vertical distance between content state logic and their utility code (variables, callbacks, and hooks) can obstruct the readability if it becomes too big.