How to write clean code in React using the best practices?

Dmytro Chumak
Dmytro Chumak
18 min read

When searching for the "Golden Fleece" of code quality, it is common to encounter mistakes. Finding an appropriate engineering solution can be time-consuming and difficult.

TL;DR

This article covers the best coding practices for developing exceptional applications.

Intro

Many developers with years of experience still write poor-quality code. There are several reasons for this:

  • Project:
    Staying in legacy projects for many years might block self-growth.
  • Unreasonable business decisions:
    How often have you heard "no time or budget for unit tests"? πŸ™ƒ
  • Time pressure:
    Often we deliver low-quality solutions with the hope to refactor them later. How often? Search for @todo in your codebase to get the answer. 😊
  • Lack of strong knowledge:
    Having different backgrounds, many of us are still not adequately educated in programming. Besides, we can interpret the information differently. The issue arises when the interpretation is incorrect.

I have a clear vision of what a React developer should know and understand to write high-quality code. Therefore, a starting point at which we are going to dig into the topic would be Patterns.

Patterns

Writing and understanding code is tightly coupled with knowing patterns. In the React world, we can define the following concepts which I would recommend you to learn:

Why is it important to learn patterns?
  • Using design patterns in your React application can help solve problems and ensure that your code is readable.
  • By knowing these patterns, React developers can easily understand what other developers mean.

DRY

DRY is commonly used as an abbreviation for "don't repeat yourself". It's a simple principle that leads to good design by reducing repetition through abstractions. Avoid mixing Single Responsibility Principle (S in SOLID) with DRY. The opposite of DRY is WET, which stands for "write everything twice".

In their book "The Pragmatic Programmer", Andy Hunt and Dave Thomas state that the DRY principle is about:

Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.

Andy Hunt & Dave Thomas

Regarding Andy and Dave, DRY is not only about code duplication, but also about databases, tests, documentation, and deployment. By eliminating duplication, you can stop worrying about common elements. Applying DRY doesn't mean eliminating it everywhere. Otherwise, DRY can lead to software that becomes difficult to understand. When trying to apply it appropriately, ask yourself two simple questions:

  • Will anybody besides you understand your abstraction? Implementing overly complex code or code that only you can understand is incorrect.
  • Does the code have different responsibilities? When the code appears similar, we often extract and reuse it, which is generally good. However, in certain cases, we may violate the Single Responsibility Principle (SRP). Wait, what did you say just now, "Single ..."? What's that? SRP requires separating things that change for different reasons and combining those that change for the same reason. In simpler terms, modifying reusable code might improve one aspect but adversely affect another. Ponder upon this and exercise caution! Sometimes it's better to tolerate code duplication (WET) than to have poor abstraction.

The DRY principle in React can be followed using:

Let's make the following form adhere to DRY principles:

import React, { useState } from 'react';
export const Demo = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
alert(JSON.stringify({ email, password }));
};
return (
<form onSubmit={handleSubmit}>
<input
name="email"
type="text"
onChange={(e) => setEmail(e.target.value)}
value={email}
/>
<input
name="password"
type="password"
onChange={(e) => setPassword(e.target.value)}
value={password}
/>
<button type="submit">Submit</button>
</form>
);
};

To add a new field and manage its state, we have to duplicate useState(). Switch the tab to see how we can fix it.

KISS

KISS is an acronym for "keep it simple, stupid". It's a straightforward and effective principle, but many of us underestimate it. Let me tell you a story:

Once, a colleague told me about a senior developer she worked with early in her career. This was a great opportunity to learn and grow. However, a few weeks later, she wasn't so optimistic. She and her teammates recognized that the code of that guy was unnecessarily complex. We can talk about the knowledge gaps of juniors, but it wasn't solely her fault. I believe the main issue was violating the KISS principle.

Obviously, we can't avoid less experienced developers joining a project. Therefore, control your ego and keep the code straightforward for others. In that case, knowledge transfer happens efficiently due to simplicity.

Following the KISS principle in React means:

  • Use well-known patterns.
  • Use the minimum number of patterns and abstractions necessary to achieve the goal.
  • Split components when they become complex and messy.
  • Avoid overusing useMemo(), useCallback(), or other features that might increase complexity.
  • Use self-descriptive naming.

YAGNI

YAGNI is an acronym that stands for "You Aren't Gonna Need It." Following this principle allows us to avoid unnecessary complexity.

YAGNI is against extending a codebase based on the presumption that we will need something in the future. Any presumption has a high chance of failure and a low chance of success. Imagine creating a complex solution that takes into account all possibilities. It's evident that we would encounter some issues until the extra code becomes useful:

  • Cost of delay caused by presumptive functionalities

  • Cost of maintenance

  • Additional complexity that can affect other code

  • Realizing that it should have been done differently

  • The possibility of the client canceling the feature altogether

  • In React applications, applying YAGNI involves:

  • Using Higher-Order Components (HOCs), Render Props, or custom hooks. It's better to create abstractions when you have more than one use case.

  • Avoiding unnecessary optimization using useMemo(), useCallback(), or React.memo. Activate these options only when a real performance issue is discovered.

  • Avoiding the retention of unused code or settings.

The important thing to remember is that you should not implement code based on guesswork about future requirements. Instead, focus on contributing what you can use right now.

Less

Having more lines of code leads to more bugs and higher technical debt. This requires more time for maintenance, which increases costs. Therefore, when it comes to code, less is more. However, this does not mean that less code is always preferable. Instead, add as much code as needed to make something work.

In programming, there are usually multiple solutions to implement the same thing. The principle is to choose the one that is readable, scalable, and maintainable.

Here are a few suggestions for following this principle:

  • Don't reinvent the wheel. Instead, use robust libraries that have already solved your problem. When I was less experienced, I believed that creating things from scratch was good. While it might be helpful for learning, it's not ideal for maintenance. Innovations make sense only when none of the robust libraries you know can solve your problem.
  • Follow DRY. DRY is a great solution for creating readable and concise code.
  • Avoid using new technologies when it's solely your preference that conflicts with business goals. For example, using CSS-in-JS technology (e.g., styled-components) may be crucial when the best performance is required.
  • Follow the KISS principle and refrain from making simple things complex.
  • Only add code that is needed now. Never comment out code with an explanation that it will be used in the future. Use Git to retrieve that code whenever necessary. Remove code that serves no purpose YAGNI.
  • Use modern Javascript syntax if your team is familiar with. For example, instead of if (object && object.prop) {} use optional chaining: if (object?.prop)

Premature optimization

You might have heard, "premature optimization is the root of all evil". Even if you haven't, believe me, 99% of developers have dealt with this complex issue. So, where does this come from? A long time ago Donald Ervin Knuth wrote:

The real problem is that programmers have spent far too much time worrying about efficiency in the wrong places and at the wrong times; premature optimization is the root of all evil (or at least most of it) in programming. There is a time and place for efficiency. We should forget about small efficiencies, say about 97% of the time. Yet we should not pass up our opportunities in that critical 3%.

Although it can't be explained better than Donald Knuth did, I'll tell you how I came to this point. A long time ago, during code reviews, I would catch myself suggesting performance optimizations without a valid reason. With much more experience today, I request changes only if I can prove the issue.

Would you always use useMemo(), useCallback(), React.memo() in React applications? No, right? Because only an expensive computation is worth memoization.

Optimize your code when it's really worth it; otherwise, you're wasting time.

Testing

Obviously, testing is a crucial part of any robust software. However, have you ever encountered anyone who insists that testing is useless, despite your efforts to persuade them otherwise? After reading this section, I hope you will be equipped with better arguments to counter such views in the future.

There are multiple types of testing in programming. A simplified list might include:

Let's examine the "test pyramid," which visualizes the amount, proportions, and costs related to writing tests.

Test pyramid

As we move up the testing pyramid, the number of tests to write decreases. However, costs increase and speed decreases. Therefore, it is better to write many small unit tests, fewer integration tests, and very few end-to-end (e2e) tests.

Unit tests are cheap and fast due to their simplicity. However, there are factors that can negatively impact this, such as:

  • Broken best practices.
    When testing a unit (class or function), it should be considered a black box. We should not be concerned with private methods, as doing so would tightly couple our test with the implementation. Consequently, refactoring the unit could require modifications to the test cases.
  • Unit testing can be complex.
    It is better to have small test cases than to put everything into one.
  • Writing tests for better coverage.
    For some developers, nothing can bring more joy than achieving 100% test coverage. Unfortunately, this can provide zero value for application stability. It's important to remember that it's better to add enough tests to ensure application stability, rather than trying to achieve 100% coverage with useless tests.
  • Using bad tooling.
    In one of my Apollo projects, I used MockedProvider in combination with Enzyme. However, when it came time to write or fix a unit test, I found myself getting headaches. While I knew what issues to expect and how to handle them, I was not satisfied with this tooling. Furthermore, when you have a larger team, the cost of using such tools can scale up significantly. Therefore, be careful when choosing your tooling.

Unit testing is a type of "snapshot" for code units that makes their behavior predictable. Ultimately, it provides numerous benefits:

  • A source of documentation:
    Unit testing helps you document and validate the correctness of code. Developers can refer to unit tests to understand how a unit works.
  • Improves code quality:
    Writing tests forces you to consider edge cases and the problem the unit needs to solve.
  • Reduces costs:
    Changes in a well-covered codebase carry less risk, making it harder to introduce new bugs and reducing costs associated with finding and fixing them.
  • Promotes good design:
    Good design allows for flexibility and maintainability, but even good designs degrade over time. Technologies and project requirements may change, and teams may adjust the design to maintain good shape. In the context of big or even small changes, having no automated tests is a huge risk. Well-written tests that validate changes can instill confidence in the team.

Writing unit tests is definitely a good practice because it improves code quality. In a React project, you can use the following packages to deal with unit testing:

I would also suggest reading the article where I describe the usage of RTL vs Enzyme.

Should we ask supervisors to write the tests?

In my opinion, writing unit tests is something that we as developers should do without question. Does a surgeon ask their boss how to wash their hands before and after surgery? Can anyone force them to do it faster? Obviously not! There is a protocol that includes around 20 steps that a surgeon must follow. Otherwise, there is a risk of infecting patients.

The only valid reason to ask for help is when we have no experience in writing tests. Otherwise, it is our responsibility to write tests or face the consequences.

Strong typing

The advantage of strongly typed languages is that errors can be detected by the compiler even before the program runs. Unfortunately, or perhaps fortunately, JavaScript is a weakly typed language. Therefore, there is no built-in mechanism to enforce strong typing in JavaScript yet.

To integrate type validation in JavaScript, we can use TypeScript. However, TypeScript has split the community into two camps: haters and fans. I understand both sides because using TypeScript has its advantages and disadvantages.

Pros of using TypeScript
  • Having types in your code brings several benefits:
    • You write fewer dummy tests, as you don't have to focus on writing validation unit tests and can instead concentrate on testing business logic.
    • The code is validated, which eliminates bugs and typos.
    • The program becomes predictable, which makes it easier to reason about.
    • The code becomes better self-documented, which makes it easier to understand.
    • You can write better-quality code using the latest JavaScript features, such as classes, modules, and generics.
  • TypeScript offers using the latest JavaScript features (classes, modules, generics, etc.)
  • Writing types in TypeScript is optional, which means that you can migrate to TypeScript iteratively.
  • TypeScript is one of the most widely used tools in the world, and as such, it has a huge community. You can find more information about this community at community
  • TypeScript also offers rich IDE support
Cons of using TypeScript
  • Complexity: Achieving good knowledge of TypeScript can be challenging due to its steep learning curve. Additionally, inexperienced developers may encounter difficulties with complex type annotations.
  • TypeScript can also increase project costs, as it requires a long-term investment that pays off later. In the short term, fixing typing issues can be time-consuming.
  • Finally, using TypeScript can result in more lines of code compared to plain JavaScript.

In my experience, using TypeScript makes sense when:

  • In middle or large projects.
  • Stability has a higher priority.
  • A project involves more than one developer. In that case at least one developer in the team should have good TypeScript knowledge. It doesn't make sense to use it only to learn the technology, as it may seriously threaten the project.

Returning to React, think of Typescript as an upgraded version of PropTypes.

Static analysis

Lazy developers love to use code analysis tools that can warn them about possible bugs, security vulnerabilities, or code smells. Using these tools can greatly reduce the effort needed to eliminate source code defects.

There are two types of code analysis: static and dynamic. Static code analysis involves inspecting the source code before a program runs. Dynamic code analysis identifies issues after a program has run.

For instance, in React, you can use Typescript, Flow, and PropTypes for type analysis purposes. The difference is that PropTypes checks types dynamically at runtime, while Typescript and Flow check the source code statically; for example, in an IDE.

I'm sure there are hundreds of cool static or dynamic code analysis tools that I'm not even aware of. However, let me share a few words about a static code analysis tool that I have experience with:

  • Eslint catches inconsistent formatting, styling, and possible errors.
  • Prettier is a code formatter only. It might work fine in pairs with with Eslint
  • SonarQube is the most powerful static analysis tool I know of that allows for the detection of:
    • Issues
    • Security Hotspots
    • Code Smells
    • Duplications
    • Code complexity
    • Test coverage
  • npm audit or yarn audit perform a vulnerability audit against the installed packages
  • Typescript

Monitoring

Monitoring is the process of systematically collecting and analyzing data on application behavior. Including monitoring tools in your project can provide valuable insights and help you make informed decisions. By analyzing data, you can compare results and detect issues. In short, monitoring can be a lifesaver. For example, it saved me on a project where we used Next.js:

Once, we released an upgrade of styled-components v5 and discovered that Node.js memory usage increased. There was no apparent reason for it, as the external factors remained the same. The first thing that came to mind was "we have a memory leak". I was thankful that we had integrated metrics into our project, as it allowed us to identify the source of the problem.

After releasing to production, we discovered a memory leak when using React SSR, Apollo, and styled-components v5. As a result, we reverted the release and began researching a solution. Fortunately, the community proposed a solution later on. Without monitoring, detecting this issue would have required significant effort. I hope my experience emphasizes the importance of monitoring for you.

Here you can find the stack we use for collecting metrics from our Node.js instances:

In addition to memory usage, there are other things you can monitor, such as:

In addition to Grafana and custom monitoring, there are other options available:

  • Google search console. There are many useful metrics and features. For example, check Core Web Vitals
  • Sentry. This tool allows for monitoring errors in both frontend and backend applications.
  • FullStory improves your development and enhances the user experience of your digital products.

Documentation

Add comments only when necessary, and ensure that they add value. Whenever possible, use self-descriptive component and method names to avoid a mess of comments. Additionally, it's best to write comments in English, regardless of your native language, as it can save time when collaborating with an international team.

I don't think I need to explain the importance of documentation. However, some developers argue that there are more important topics and do not consider documentation to be essential.

Can any solid open-source project grow without a good README.md? No, right? Therefore, I strongly recommend documenting at least the following:

  • Architecture
  • Business Requirements
  • Details of complex features
  • Steps for installation
  • Process for deployment
  • Steps for release

Having clean documentation saves a lot of time when new teammates join your team.

Conclusions

Knowing and using best practices helps us contribute to clean code. However, clean code is not the end goal. The goal is to simplify complexity, improve maintainability, and increase confidence in changes. Don't become a fanatic of clean code. First, consider if refactoring brings better value to your product or team before making any changes.

Do you like the article or have some remarks? Let's discuss it!

Join the Newsletter

Subscribe to be up to date by email.
Cool content and no spam.

tridenttridentGlory to Ukraine!