Creating bugđź•·free React Apps

A good strategy can eliminate several classes of bugs

Rajesh Naroth
Level Up Coding

--

Image by Annette Meyer from Pixabay

According to the paper The Mythical Man Month by Frederick Brooks, developers are optimists and I agree. Most of us tend to focus on the happy path of a feature, working within the warmth of hot-reloads, VSCode and Chrome dev tools, trusting the APIs to always work and assuming that if anything is wrong, QA will catch it. If only.

In this article, I’ll try to present practical steps we can take to eliminate certain classes of bugs from large JavaScript applications. Some may seem simple and straightforward, some a bit over-engineered, or controversial, yet these are from experience and not just speculation.

đź•· Incorrect function arguments

JavaScript is a dynamically typed language and a very forgiving one in terms of function calls. You could call a function with no arguments or extra arguments no matter how the function was defined. Within a small scope, wrong usage may not seem to be a big issue, but as an application grows, so does its reusable functions and components. As it ages, the chances of calling a function with incorrect parameters (object instead of array, etc), no arguments, arguments in the wrong order, grows. No amount of unit test can really capture all of these, as some will rear their ugly head only during run time.

Tip: Use TypeScript

IMO, the largest benefit of TypeScript is in its ability to self-document code and intention, and its integration with VSCode is fantastic. Even with no specific types, TypeScript will provide enough protection with type and signature inference. One warning though: while applying static typing to a dynamically typed language such as JavaScript, there is bound to be some collision. Having an effective TypeScript strategy is needed. The following article may help:

đź•· JavaScript bad parts

JavaScript is a multi-paradigm language, with OOP constructs added as syntax sugar. “this” is a flawed and a confusing keyword, as are constructors and the new keyword. It takes effort to properly understand prototypes and how they work within the construct of classes. Features not fully understood will lead to subpar code with bugs.

Tip: Use a trimmed version of JavaScript by choice

Here is an article that can help adopt a trimmed JavaScript style:

đź•· Unintended mutations

Imperative code (while, for, if/else) is rife with mutable state and thus becomes a playground for bugs.

Tip: Use immutable state

For simple data types, use const. let should be an exception, never use var. This article should give a bit more clarity into this:

Arrays and Objects

Arrays and objects can be mutated even when defined as a const. Object.freeze() cannot handle nested JSON objects. TypeScript comes to the rescue:

// Arraysconst list: ReadonlyArray<number> = [ 1, 2, 3];
// list[2] = 10; // is not allowed
// Objectsinterface IPoint {
x: number;
y: number;
}
const point: Readonly<IPoint> = { x: 3, y: 4 };
// point.x = 10; // is not possible

Try them here: https://codesandbox.io/s/immutable-state-ujsc3?file=/src/App.tsx

Tip: Use pure functions as much as possible.

Be in the habit of writing pure functions as much as possible. Pure functions have no state mutations and they are very easy to unit test.

Tip: Write small functions.

Large functions are a code smell: they do too much and even when there are no mutations outside themselves, they may be tracking a large set of state variables that are prone to accidental errors. With practice, you will be able to figure out how to split a large function into smaller units that “does one thing”. Keep the small functions pure as much as possible.

Tip: Stop using classes

Stop using class components. In our team at HPE, we recently completed two large applications with zero class components. It was great, we didn't miss anything.

OOP vs Functional Programming is outside the scope of this article. Personally, after writing code with clear boundaries between data and functions, it is very hard to go back to OOP where everything is a class. Maybe this article will change your mind.

Tip: Avoid Imperative loops

Functional programming tools such as map, filter, reduce, compose etc, are time tested constructs that we can substitute imperative iterations with. Thus, minimizing or totally eliminating usage of while, for, forEach etc, would go a long way to suppress this class of bugs. I cannot remember the last time I used a while or for loop in JavaScript. Here is an article that goes a bit more deep into this topic.

🕷 The dreaded “Cannot read property of undefined”

This happens when you try to access a property of an object that has not yet been initialized, say for example, when an api does not return something the View expected. Thus we were advised to write abominations like this:

const firstName = model && model.address && model.address.name || model.address.name.first || "";

Or, we use abstractions from libraries such as lodash or ramda.

Tip: Use the ?. optional chain operator — with defaults

IMO, out of all the new JavaScript features, the optional chaining operator tops my list. It reduces code density by simplifying the error check expression. The previous example now becomes:

const firstName =  model?.address?.name?.first || "";

Our UI team follows this so religiously, it becomes one of the main things called out during code reviews. Though rare, we even use optional chaining for flow control:

if (onClose) {
onClose();
}
// can be substituted with:
onClose?.();

Learn more about optional chaining here:

Tip: Use function parameter defaults.

Unless your logic explicitly needs a null/undefined value, there is no reason for ANY state to be uninitialized. Using const can help you mitigate this, const definitions are always initialized. But, passing an undefined value into a function is not prevented automatically. Function parameter defaults will substitute a provided default when it is called with an undefined value.

đź•· Runtime data errors

REST remains the de-facto API protocol for a vast number of web applications. REST responses often are arrays of complex JSON structures and they evolve over time — there is always a risk of the UI and the REST API to be out of sync. Consumption of an API result directly into a React view is very common, most of us won’t even think twice about it. However, after working in a few large enterprise applications, I’ve come to realize that a lot of bugs happen because of this. API data can be trusted but should always be verified. Tightly coupling views directly to API responses is risky.

Tip: Isolate Views from API

Create a model interface (use TypeScript) that represents just the View. Unlike an API data, it contains just enough information that a view can bind to, nothing more, nothing less — a simple JSON object often with very little nesting. Create a model adapter, a pure transformer function that transforms API data.

Take a look at this codesandbox React example.

In this example we’re displaying a bunch of addresses. The API returns:

[
{
"id":0,
"firstName":"Mathilde",
"lastName":"Wilderman",
"street":"697 Hudson Stream",
"city":"Lake Ayanaport",
"state":"North Dakota",
"zipcode":"80363-3093",
"status":"unread"
},
{
"id":1,
"firstName":"Anjali",
"lastName":"Renner",
"street":"204 Myra Keys",
"city":"East Amparo",
"state":"Massachusetts",
"zipcode":"66131",
"status":"marked"
}
]

It is tempting to consume these values directly into the view like so:

      <div >
<span> Status: {status === "marked" ? "marked" : "unread"}</span>
<div>
<div className="name">{`$firstName $lastName`}</div>
<div className="street">{street}</div>
<div className="street">{city}</div>
<div className="street">
{state} {zipcode}
</div>
</div>
</div>

But as you see, the api is returning some properties such as the zip code in multiple formats. The name and status fields also require modification. Stuffing all that logic within the View creates clutter and makes it unfriendly to unit testing. Consider using this transformation function instead.

const toSimpleZip = (zipcode: string) => (zipcode?.split("-") || [])?.[0] || "n/a";const getIcon = (status: string) => {
const DEFAULT = "email";
const statusMap: object = {
unread: "mark_email_unread",
marked: "mark_email_read",
read: DEFAULT
};
return statusMap[`${status}`] || DEFAULT;
};
const apiToModel = (address) => ({
name: getFullName(address.firstName, address.lastName),
street: address.street || "",
city: address.city || "",
state: address.state || "",
statusIcon: getIcon(address.status),
zipcode: toSimpleZip(address.zipcode)
});

Creating an adaptor like this provides certain benefits:

  • Views are now simple and can be unit tested easily.
  • The adapter can provide fallback values if the API fails provide it. No more unsightly ”undefined” or “null” strings in the UI.
  • The adapter and its associated transform functions can be unit tested easily for positive and negative conditions.
  • Having a clear definition of what the API provides and what the view needs, leads to clear type interfaces. This leads the code to self document.

Tip: Isolate Forms from API

One of the most time consuming and toughest part of UI is writing good forms. Many bugs arise from a mismatch between the form model and the API it interacts with. A form does not necessarily model the exact shape of the API data, a form’s model is usually flat while the API is nested. Thus having adapters to transform data to/from forms help reduce a bunch of errors. I’ve covered this in another blog in the context of Formik.

Tests

This topic has been harped on for decades, yet testing takes a backseat and becomes a technical debt in many projects because of many valid sounding reasons. Tests become all the more important as the software ages and changes ownership. It is outside the scope of this article to go into details but we follow this in our team:

  • Write pure functions as much as possible, they are easy to test.
  • If a piece of logic can be made unit testable by moving it into its own function, it should be.
  • Unit tests should have positive and negative test cases. Null checks and default behavior should be provided where applicable.
  • Write acceptance tests for parts that cannot be unit tested.
  • Use a code coverage tool to reveal missing test areas.

Hope these tips are useful to you. Are there conventions that you adopt in your team that have helped you to reduce bug density? Please let me know in the comments.

--

--