Gustavo Santos

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 with front-end development with React.

This a compiled list of personal rules of thumb that I like to follow during front-end development, specifically with React. Maybe 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 it’s not self-explanatory and code should be self-explanatory (until it doesn’t, when we need to seek for performance against readiness).

Unit tests serve as documentation, a kind of documentation that we can run and verify if it works as expected. The tip here is to write most unit tests that exercise only one thing at the 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 these other hooks and exercise them together with the hook under test. By writing integrated tests, you lose the ability of using the test as documentation because the test becomes so messy that you need to use a lot of brain power just to read the test and understand what’s going on.

Use Test to Drive the Development

More than using tests for documentation, it’s very useful to use them to drive the 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 to 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 the tests for a walking skeleton.

What the feature should 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 some changes that need to touch on such components. Components receiving 10 or more properties and doing things depending on the combination of these 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 a thing. I’m not going to go deeper on this because I already wrote about this here.

Every Meaningful Markup Should Be a Component

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

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

Instead of creating a monstrosity of a component that have 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 the god component.

Model Based on Functional Core and Imperative Shell

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

React isn’t the most efficient library to build UI, there are better options for that, such as Svelte. In the first implementation, you just need to get your code readable and decoupled. The next step, if needed, is to make it run fast. The easiest way to get a 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 in bigger components that represent a more broad context. But instead of just 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 have your work done faster.

Don’t Write Effect Management by Yourself

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

Almost always, we will do a pretty bad job by writing effectful code management with plenty of flaws. The more common flaw is to leak memory. I already mentioned that React is not the best efficient library, we don’t need to take the application performance down with memory leak.

Avoid that by adopting libraries such 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 Rollout Features

I already wrote some lines about this topic, you can check out them here. Rolling out using feature flags allows you to test in production a set of features 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 maybe an embedded system.

Being able to remotely enable or disable features is the best way to rollout 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 into one place, you can decoupled related pieces into custom hooks. This is the first step.

The next step is to make such units reusable when needed. Only if needed.

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

Have a Good Monitoring Tooling

Knowing how your application is behaving inside the user’s browser is critical. How much memory is the application using? Which are the logs? Which errors the user is facing? They are recoverable errors or not? We should 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 are your friend here.

Have an Acceptance Tests Suite that Runs Automatically

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

I think that having a set of acceptance tests that you can run during the development and also run, for example, at every hour, are 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 and that bug 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 Citizen

I think that 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 React application (causing that blank page), many developers just don’t care and think that their applications are error free.

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

If you already played with Go or Rust, you know that these programming languages have some 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 it happened. Rust, on the other hand, often returns a Result enum, where it 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 a state of error, functions returning a tuple of values and so on.

There are errors that we don’t care about from the observability perspective. Errors like input validation or runtime rescuable errors. But there are errors that we do care about, errors like invalid state entrance, HTTP endpoints responding with error status code 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 those errors and offer ways to put the user back in a valid application state.

Avoid Depending Too Much on IA Assistance

GitHub Copilot is a marvel tool, but I often see people using it too much. Don’t let your AI assistant to write your tests, use it to write only the boilerplate code. Tests are too important to let an IA 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 on such things, I think that taking care of test complexity and what they are doing is a crucial part of working professionally as a software engineer.

Also, I saw people using IA tools to write pull request descriptions. I think that this is not the right thing to do, the description generated is too raw, it doesn’t provide contextual information such as “why this pull request exist?”, or “what problem it is solving?”, or event “this thing is part of a bigger feature?“. I found so 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 the “why” a change was made. When you find the pull request that introduces the change, you then figure out that it doesn’t provide any useful information, just generic text spit from an IA bot.

Be a Catalyst for Change

If you read The Pragmatic Programmer, you know this tip, but I think that 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 to just don’t know, it’s fine, it’s expected that you or your colleague don’t 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 a chance, show your team with a new way of roll outing features. Maybe suggest creating (or you do it) 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 cause a good impact on the organization. But observe if the company isn’t recognizing your effort, talk to them and if it’s the case, maybe then it’s time for a change.