Rules of Thumb on Front-End Development with React

An incomplete set of rules that I follow when I'm doing software engineering stuff related to front-end development with React.

Gustavo Santos Jun 13, 2023

This is a compiled list of personal rules of thumb that I like to follow during front-end development, specifically with React. Some tips could work on other technologies, such as Svelte, Vue, and so on, but here I’ll focus on React and its abstractions and well-known patterns.

Seek to Write Tests as Documentation

We don’t add comments to our code. It’s well-known that if we need to write comments in the code, maybe the code is not self-explanatory—and code should be self-explanatory (until it isn’t, such as when we need to prioritize performance over readability).

Unit tests serve as documentation—a kind of documentation that we can run and verify to ensure it works as expected. The tip here is to write unit tests that exercise only one thing at a time. Seek for microtests; this will help you to write more decoupled and concise components and hooks.

For example, if a React custom hook depends on two other hooks to get its job done, then mock these other hooks instead of providing all the infrastructure to integrate and exercise them together with the hook under test. By writing integrated tests, you lose the ability to use the test as documentation because the test becomes so messy that you need to use a lot of brain power just to read it and understand what’s going on.

Use Tests to Drive Development

Beyond using tests for documentation, it’s very useful to use them to drive development. I like to take different approaches here depending on the context.

If your task is to make a small change, I’d suggest you drive the development using the already existing unit test suite. Just add some more test cases to cover your new implementation, and you are good to go.

But if you are implementing a new feature, I think that the best way is to begin by writing tests for a walking skeleton.

What should the feature do? Answer this question using a set of acceptance tests—tests that run on a real browser, without mocking any dependency (maybe just mocking HTTP requests). When you get the walking skeleton working, then you begin writing unit tests and then refactoring the source code.

Avoid God Components

Unfortunately, I commonly see components so big that developers and managers avoid any changes that need to touch them. These components often receive 10 or more properties and do things depending on various combinations of those properties.

I believe that a healthy way to define React components is by composing them instead of providing a big block of code that does everything. I’m not going to go deeper into this because I already wrote about it here.

Every Meaningful Markup Should Be a Component

React doesn’t have a maximum number of components that need to be defined. Neither do you need to create a file for every component that exists inside your codebase.

Just as a file can contain a set of functions that are related to the context of the module, a file can also contain a set of components that are used in combination inside a bigger component.

Instead of creating a monstrosity of a component that has a lot of HTML markup inside, try to extract parts of the HTML in the same way that you extract parts of a class and decompose it into many small methods. Then compose these components to create the bigger component, always paying attention to avoid creating a god component.

Model Based on Functional Core and Imperative Shell

Model the application based on small functional pieces. These don’t need to be just pure functions that operate on data, but can be objects that have a value and a set of methods.

React isn’t the most efficient library to build a UI; there are better options for that, such as Svelte. In the first implementation, you just need to make your code readable and decoupled. The next step, if needed, is to make it run fast. The easiest way to get readable code on the first try is to organize your code using the functional core and imperative shell approach.

Model components to depend only on properties. Model hooks to have isolated state/effects management. Then write those into bigger components that represent a broader context. But instead of writing every state management-related code on such components, isolate stuff using hooks. The bigger component/hook will be your imperative shell.

Test the imperative shell using acceptance tests, for example, Playwright tests. Drive the development of the smaller units using unit tests. By doing this, you will get your work done faster.

Don’t Write Effect Management Yourself

Things such as making HTTP requests could be managed by React Query or a related library. Such libraries abstract a lot of code that you would otherwise need to write and maintain to deal with caching, revalidation, sharing data, and so on.

Almost always, we will do a pretty bad job of writing management for effectful code, leaving plenty of flaws. The most common flaw is a memory leak. I already mentioned that React is not the most efficient library; we don’t need to slow the application down with memory leaks.

Avoid that by adopting libraries such as React Query; it will save you many hours debugging bugs related to effect management.

Effects here are any operations that are related to promises.

Use Feature Flags to Roll Out Features

I already wrote some lines about this topic; you can check them out here. Rolling out using feature flags allows you to test a set of features in production without enabling them for all users of your system. It’s a technique that you can use in any kind of system, such as a web system or an embedded system.

Being able to remotely enable or disable features is the best way to roll out new features and even test in production.

Make it Reusable When Needed

You don’t need to write reusable code if you don’t need to reuse it right now. Solve the problem first and write only the code needed to get it done. But do it professionally.

The code must be decoupled and easy to read, not a messy net of a lot of stuff wired together that is fragile to change. You don’t need to implement all the stuff related to state management in one place; you can decouple related pieces into custom hooks. This is the first step.

The next step is to make such units reusable when needed—and only if needed.

But I believe that we should take this with a grain of salt. Following YAGNI as a mantra could eventually create a big ball of mud—code that no one likes to open, read, and maintain. The tip here is to balance “good enough” code and unwanted abstractions.

Have Good Monitoring Tooling

Knowing how your application is behaving inside the user’s browser is critical. How much memory is the application using? What are the logs? What errors is the user facing? Are they recoverable errors or not? Should we disable a feature due to errors, or maybe we need to roll back to an older version?

The team must know how to answer these questions, and the best way to do that is by having a solid monitoring system. Tools like Grafana, Kibana, Sentry, and many other SaaS options are your friends here.

Have an Acceptance Test Suite that Runs Automatically

I’m a big fan of Playwright. It’s a robust platform to write acceptance tests (or kind of end-to-end tests), with a fantastic developer experience, and it’s very efficient at running tests.

I think that having a set of acceptance tests that you can run during development and also run, for example, every hour, is the best way to discover if everything is working as expected in production.

This is different from monitoring errors; a feature could have a bug that isn’t exposing any kind of error or unwanted log.

A test suite like this helped me to catch a bug that only happened on Fridays.

Treat Errors as First-Class Citizens

I think many programmers just don’t care about error management. I’ve seen people just throwing exceptions without knowing if they will be caught or treated correctly in some upper scope. Even though we know that an exception that is not caught by the runtime can break a React application (causing a blank page), many developers just don’t care and think their applications are error-free.

I like to take another approach. I think that errors shouldn’t be thrown; instead, they should be returned from functions.

If you have already played with Go or Rust, you know that these programming languages have certain patterns for error handling. Go just returns a tuple of values, where the first value is the result itself and the second is an error if one occurred. Rust, on the other hand, often returns a Result enum, which can be either Ok or Err, both representing the success or the error result.

In our React applications, we can follow the same approach, having custom React hooks returning an error state, functions returning a tuple of values, and so on.

There are errors that we don’t care about from an observability perspective—errors like input validation or runtime-rescuable errors. But there are errors that we do care about: errors like invalid state entry, HTTP endpoints responding with error status codes, and so on—errors that represent that something that should not happen, happened. These errors need to be known by the company, and the application should handle them and offer ways to put the user back into a valid state.

Avoid Depending Too Much on AI Assistance

GitHub Copilot is a marvelous tool, but I often see people using it too much. Don’t let your AI assistant write your tests; use it to write only the boilerplate code. Tests are too important to let an AI write them for you. I think that if you are considering using an AI assistant to write tests for you, maybe your tests aren’t well-organized. Perhaps you are testing too much stuff at once, and it’s requiring a lot of setup code that you don’t want to write.

I suggest paying attention to such things. Taking care of test complexity and what they are doing is a crucial part of working professionally as a software engineer.

Also, I’ve seen people using AI tools to write pull request descriptions. I don’t think this is the right thing to do; the description generated is often too raw and doesn’t provide contextual information such as “why does this pull request exist?”, “what problem is it solving?”, or “is this part of a bigger feature?”. I find it difficult to keep track of stuff when people use these tools and don’t provide extra information.

It’s quite common to browse the Git history to find out “why” a change was made. When you find the pull request that introduced the change, you might figure out that it doesn’t provide any useful information—just generic text spat out from an AI bot.

Be a Catalyst for Change

If you have read The Pragmatic Programmer, you know this tip, but I think it is well-suited to be used here as well.

Maybe something that I mentioned on this page isn’t used by your company—perhaps because the engineering leadership is trying to contain expenses, or perhaps they just don’t know. See, it’s very normal just to not know; it’s fine and expected that you or your colleagues might not have knowledge about something. Once you discover a new way to properly do software engineering, you’ll become a better professional, and this is also expected.

If you have the chance, show your team a new way of rolling out features. Maybe suggest creating (or doing it yourself) a scheduled cron job to run acceptance tests. Show them new ways to test code. Pay attention to the details and come up with improvements.

I believe that we should take daily adversities as an opportunity to have a positive impact on the organization. But observe if the company is recognizing your effort; talk to them and, if it’s the case, maybe then it’s time for a change.