Let's continue our series of short posts about code refactoring! In it, we discuss technics and tools that can help you improve your code and projects.
Today we will talk about simplifying complex conditions using the early return technic. As an example, we will use a React component and improve its render function.
Component Code
Before we dive into refactoring itself, let's investigate the code we're going to work with.
Here we have a Checkout
component that fetches a cart for a particular user and renders either the checkout form, the success checkout message, an error, or the loading state indicator:
function Cart({ user }) {
// Imagine, the `useCart` hook is responsible
// for fetching the cart by the user id,
// handling the fetching status and errors,
// and handling the submit event:
const { cart, status, error, onSubmit } = useCart(user.id);
// Assume that the status can be in 4 states:
// error, idle, loading, and submitted.
const hasError = status === "error";
const isIdle = status === "idle";
const isLoading = status === "loading";
// The render condition is complicated
// and difficult to read and reason about:
if (!hasError) {
if (isIdle) {
return (
<form onSubmit={onSubmit}>
<ProductList products={cart.products} />
<button>Purchase</button>
</form>
);
} else if (isLoading) {
return "Loading...";
}
if (!isLoading && !isIdle) {
return "We'll call you to confirm the order.";
}
} else {
return error;
}
}
Let's assume that the main logic of fetching the data, submitting the form, and updating the status is encapsulated in the useCart
hook, and the component is only responsible for the render of the UI.
With that in mind, we can analyze the code and see what problems we can find in it.
Code Complexity
The main issue with the component's render function is its complexity. But it takes only 17 lines of code, so why do we see it as complex?
To answer that, let's pay attention to the level of nesting in this code:
function Checkout() {
__// ...
__if (!hasError) {
____if (isIdle) {
______return (...)
____} else if (isLoading) {
______return "Loading...";
____}
____
____if (!isLoading && !isIdle) {
______return "We'll call you to confirm the order.";
____}
__} else {
____return error;
__}
}
The empty space on the left side of the text shows how much information the snippet contains.
The more information there is, the more details we need to keep in mind when working with the code. When we read it, our working memory is occupied by a big number of details and it's hard to keep track of what's going on in the code. So when we see deeply nested code, we already know it will be difficult to understand.
There's actually a code metric that describes this phenomenon called the cognitive complexity.
So how can we make the code better?
Making the Code Better
When we see complex conditions, we can apply various different technics to make them simpler. One of those technics is the early return.
Our primary goal, in this case, will be to make the condition perceptionally simpler while keeping its meaning the same. We can achieve this by making the condition flatter.
As we mentioned earlier, the main issue of this code is the large number of details we need to keep in mind simultaneously. But notice that some of the condition branches contain the “edge cases”:
function Checkout({ user }) {
// ...
if (!hasError) {
if (isIdle) {
// Happy Path.
} else if (isLoading) {
// Edge case for the “Loading” state.
}
if (!isLoading && !isIdle) {
// Edge case for the “Submitted” state.
}
} else {
// Edge case for the “Error” state.
}
}
What if we turn the condition “inside out” and handle these branches first?
function Checkout({ user }) {
// ...
// Handle the “Error” state edge case first:
if (hasError) return error;
// Then, handle everything else:
if (isIdle) {
// ...
} else if (isLoading) {
// ...
}
if (!isLoading && !isIdle) {
// ...
}
}
The level of nesting has dropped, and the condition already seems simpler. The key word here is “seems” because the actual functionality stays the same. We only changed the execution order and how much information we have to remember.
Let's continue:
function Checkout({ user }) {
// ...
if (hasError) return error;
if (!isLoading && !isIdle) return "We'll call you to confirm the order.";
if (isLoading) return "Loading...";
if (isIdle) {
// Happy Path.
}
}
Again, we take the “edge case” branches and handle them first. This way we sort of “filter out” the UI states that aren't the “Happy Path”—the idle form state.
Using the early return, we can also notice some unnecessary checks that we haven't noticed before. For example, we can slightly rearrange the order and get rid of extra checks:
function Checkout({ user }) {
// ...
// (Here, we use the assumption
// that `status` can be only in 4 states:
// loading | idle | error | submitted.
// So in the condition we now:
// - first, we check for `error`,
// - then, for `loading`,
// - then, for `submitted`,
// - and finally, we're left only with `idle`.
if (hasError) return error;
if (isLoading) return "Loading...";
if (!isIdle) return "We'll call you to confirm the order.";
// Happy Path.
}
The condition now became flat. It checks the edge cases one by one and returns if we come across one of them. If the function continues to work, it means no edge cases have happened.
Filtering edge cases allows us to forget the checked branches. They no longer take up our working memory, and perceptually the condition becomes simpler.
Comparing Results
Okay, let's compile the final refactoring results and compare the code. Here's the initial code:
function Checkout({ user }) {
const { cart, status, error, onSubmit } = useCart(user.id);
const hasError = status === "error";
const isIdle = status === "idle";
const isLoading = status === "loading";
if (hasError) {
if (isIdle) {
return (
<form onSubmit={onSubmit}>
<ProductList products={cart.products} />
<button>Purchase</button>
</form>
);
} else if (isLoading) {
return "Loading...";
}
if (!isLoading && !isIdle) {
return "We'll call you to confirm the order.";
}
} else {
return error;
}
}
And here's the refactored version:
function Checkout({ user }) {
const { cart, status, error, onSubmit } = useCart(user.id);
const hasError = status === "error";
const isLoading = status === "loading";
const isSubmitted = state === "submitted";
if (hasError) return error;
if (isLoading) return "Loading...";
if (isSubmitted) return "We'll call you to confirm the order.";
return (
<form onSubmit={onSubmit}>
<ProductList products={cart.products} />
<button>Purchase</button>
</form>
);
}
Now, the render function is more straightforward and less “scary”. There's less empty space on the left, so it doesn't immediately trigger us to expect a lot of information.
We check edge cases one by one and filter them out. At the end of the function, we only have to handle the “Happy Path” case.
Basically, we went from this way of thinking about the condition:
...To this one:
The functionality stays the same, but the condition seems simpler and easier to read now.
The early return is applicable not only to the rendering function but to any conditions in general. It may not be suitable for “defensive” programming: when we explicitly handle each branch of a condition. But as a default strategy, it can be helpful when refactoring code.
More About Refactoring in My Book
In this post, we only discussed one technic that can help us make the conditions flatter and more readable.
We haven't mentioned design patterns (like Strategy), Boolean algebra (De Morgan's laws), or pattern matching. We also haven't discussed the state machines, which, in my opinion, are much better for working with conditional component rendering than just early return.
If you want to know more about these and refactoring in general, I encourage you to check out my online book:
The book is free and available on GitHub. In it, I explain the topic in more detail and with more examples.
Hope you find it helpful! Enjoy the book 🙌
Top comments (0)