React Best Practices Handbook - Part I

In this comprehensive guide, we delve into the art of writing "clean code" in React.js. Having embarked on my React journey five years ago and continuing to utilize it in my role as a Software Engineer, I've encountered various challenges along the way. Reflecting on past experiences, I strive to approach them in a more refined manner moving forward.

React stands out as one of the most prominent technologies in the realm of JavaScript, often hailed as the preferred choice by many developers. Unlike some frameworks, React grants developers the freedom to structure projects as they see fit. While this fosters creativity, it can also lead to disorder if not managed properly, particularly when collaborating within a team setting. Hence, establishing a clear and comprehensible structure becomes imperative.

Within this article, I aim to uncover 47 invaluable tips for crafting exemplary code in React, fostering improved development practices, and enhancing project efficiency.

Avoid local state as much as possible

You should refrain from creating a local state unless absolutely necessary. For instance, if you're performing calculations, avoid creating an additional variable solely for calculation purposes. Instead, consider integrating your calculations directly into the JSX.

import React, { useEffect, useState } from 'react';

const App: React.FC = () => {
  // ❌ Avoid: Unnecessary state
  const [result, setResult] = useState();

  // Considering a and b are two values coming from some API.
  const { a, b } = apiCall();
  // ❌ Avoid: Unnecessary useEffect
  useEffect(() => {
    setResult(a + b);
  }, [a, b]);

  return (
    <div>
      {result}
    </div>
  );
}

export default App;
import React, { useEffect, useState } from 'react';

const App: React.FC = () => {

  // Considering a and b are two values coming from some API.
  const { a, b } = apiCall();
  // ✅ Good: You can move it into the JSX
  return (
    <div>
      {a + b}
    </div>
  );
}

export default App;

Integrate Typescript(or at least use default props and prop types)

TypeScript provides superior static typing compared to JavaScript. In JavaScript, being a dynamic-typing language, you can define a variable with one type and later assign it a different type, which may cause errors in your application. TypeScript offers numerous advantages, including static type checking, enhanced code completion in your IDE, improved developer experience, and the ability to catch type errors while writing code.

Learning and integrating TypeScript into your projects is highly worthwhile. Here's an informative article that provides an overview of using TypeScript in React applications, covering its benefits and drawbacks. Additionally, here's a tutorial on coding your React apps using TypeScript.

There may be reasons you don't want to use Typescript inside your React application. That is fine. But at a bare minimum, I would recommend that you use prop-types and default-props for your components to ensure you don't mess up your prop.

import React, { useState } from 'react';
// 📝 Note: You can export types & interfaces from external file to avoid long component files
interface IComment {
  id: string;
  content: string;
  date: Date;
}

interface IArticle {
  title: string;
  author: string;
  date: Date;
  body: string;
  views: number;
  comments: IComment[];
}

export const Aricle: React.FC<IArticle> = ({ title, author, date, body, views, comments }) => {

  const [variable, setVariable] = useState<IArticle | null>(null)

  return (
    <div>
      <h2>{title}</h2>
      <h4>{author}</h4>
      <h4>Published the {date}</h4>
      <p>{body}</p>
    </div>
  )
}

Keep your key prop unique across the whole app

When mapping over an array to render its data, you always have to define a key property for each element. Using key props is important because it helps React identify the exact element that has changed, is added, or is removed. A common practice I have seen and used myself as well is to use simply the index of each element as a key prop.

// ❌ Avoid using index as a key 
const SeasonScores = ({ seasonScoresData }) => {
    return (
        <>
            <h3>Our scores in this season:<h3>
            {seasonScoresData.map((score, index) => (
                <div key={index}>
                    <p>{score.oponennt}</p>
                    <p>{score.value}</p>
                </div>
            ))}
        </>
    )
}

Using index as key props can lead to "incorrect rendering", especially when adding, removing, or reordering the list items. It can result in poor performance and incorrect component updates.

// ✅ Using unique and stable identifiers
const renderItem = (todo, index) => {
  const {id, name} = todo;
  return <li key={id}> {name} </>
}
  • Efficiently update and reorder components in lists.

  • Reducing potential rendering issues.

  • Avoids incorrect component updates.

Consider using React Fragments

In React, a component is required to return a single element. If you find yourself needing to return multiple elements within a React component, your initial reaction might be to wrap them with a <div> without any classNames.

// ❌ Avoid unnecessary wrapper div
const Todo = () => (
  <div>
    <h1>Title</h1>
    <ul>
      // ...
    </ul>
  </div>
);

While this approach may suffice, the unnecessary wrapper <div> can introduce complexity to the DOM structure, potentially affecting the accessibility of your web page.

Instead, consider using <Fragment> to wrap these elements. Fragments offer cleaner code by eliminating the need for unnecessary wrapper divs when rendering multiple elements.

// ✅ Use fragments
const Todo = () => (
  <>
    <h1>Title</h1>
    <ul>
      // ...
    </ul>
  </>
);

Prefixing variables and methods

Naming is crucial for enhancing code readability as it reflects the purpose of variables and methods. Consider using prefixes to make tracking easier.

  • The prefixes is and has are typically used with boolean-typed variables, signaling that the variable holds a boolean value. Similarly, methods can be prefixed with is or has to indicate that they return a boolean value.

      import { Modal } from 'antd';
      import React, { useState } from 'react';
      import SomeComponent from 'somewhere';
    
      export const Aricle: React.FC = () => {
    
        const [isOpen, setOpen] = useState(false);
        const [hasParent, setParent] = useState(false);
    
        return (
          <div>
            <Modal open={isOpen} />
            <SomeComponent parent={hasParent} />
          </div>
        )
      }
    
  • The prefixes handle and on should be exclusively used with methods to facilitate recognition that they are indeed methods and to clarify their purpose.

    • handle prefix denotes that a method will be passed to an event listener and will be invoked once the event is triggered.

        import { Modal } from 'antd';
        import React, { useState } from 'react';
      
        export const Aricle: React.FC = () => {
      
          const [isOpen, setOpen] = useState(false);
      
          // Indicates that a method is used as 'event callback'
          const handleToggleModal = () => setOpen((prev) => !prev);
      
          return (
            <div>
              <Modal open={isOpen} />
              <button onClick={handleToggleModal}>Toggle show</button>
            </div>
          )
        }
      
    • on prefix is commonly used with prop names when passing a method as a callback to another component. Received props can vary in type and the on prefix signifies that the prop serves as a callback just by reading its name.

        import React from 'react';
        import Form from './components/Form';
      
        export const CreateUser: React.FC = () => {
      
          const handleFormSubmit = () => {
            // Send data
          };
      
          return (
            <div>
              <Form onFormSubmit={handleFormSubmit} />
            </div>
          )
        }
      

Keep your code DRY

Remember the principle of Don't Repeat Yourself (DRY) whenever you encounter code duplication. By adhering to DRY, you can avoid redundant code, improve code readability, and simplify maintenance. When implementing DRY, create reusable methods instead of duplicating code. This approach, known as modular coding, ensures that changes only need to be made in one place, reducing effort and minimizing the risk of inconsistencies."

// ❌ Avoid: Redundant code
const cyberSecTotalStudents = 80;
const myCyberSecClassStudents = 24;

const AITotalStudents = 150;
const myAIClassStudents = 24;

const cyberSecPercentage = (myCyberSecClassStudents / cyberSecTotalStudents) * 100;
const AIPercentage = (myAIClassStudents / AITotalStudents) * 100;
// ✅ Good: Modular code
const cyberSecTotalStudents = 80;
const myCyberSecClassStudents = 24;

const AITotalStudents = 150;
const myAIClassStudents = 24;

const calculatePercentage = (portion, total) => (portion / total) * 100;

const cyberSecPercentage = calculatePercentage(myCyberSecClassStudents, cyberSecTotalStudents);
const AIPercentage = calculatePercentage(myAIClassStudents, AITotalStudents);

Avoid anonymous functions in your HTML

A JavaScript function is a block of code designed to perform a specific task. When defining a function, memory space is allocated to store it.

import React from 'react';

export const Form: React.FC = () => {

  return (
    <form onSubmit={
      // ❌ Avoid: re-created on every render
      () => {
        // Send data
      }}
    >
      {/ Some inputs /}
      <button type="submit">Send</button>
    </form>
  )
}
import React from 'react';

export const Form: React.FC = () => {
  // ✅ Good: Loaded in memory
  const handleFormSubmit = () => {
    // Send data
  };

  return (
    <form onSubmit={handleFormSubmit}>
      {/ Some inputs /}
      <button type="submit">Send</button>
    </form>
  )
}

Both functions perform the same task. However, using a named function enhances code clarity and readability compared to using an anonymous function. Additionally, a named function is stored in memory, and when called, it is invoked by its reference in memory. On the other hand, anonymous functions are not stored in memory. They are recreated on every render, resulting in a different function being used for each render.

Consider passing a callback argument to useState setter method

Passing a callback argument to the useState setter function in React is necessary when the new state value depends on the previous state. That ensures that we are going with the most up-to-date state value when updating the state asynchronously.

Here's why it's needed.

  • Asynchronous Updates: State updates in React are asynchronous. If you try to update the state based on its current value directly, you may encounter issues with stale state or race conditions. Passing a callback function to the setter ensures that the latest state value is used when updating the state.

  • Consistent state: By using the callback approach, React guarantees you are always working with the most recent state value, regardless of when the update occurs. This helps maintain consistency of state throughout your component.

Here's an example to illustrate this:

import React, { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  const increment = () => {
    // This is the incorrect way to update state based on the previous value
    // setCount(count + 1);

    // Correct way: Pass a callback to the setter
    setCount(prevCount => prevCount + 1);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

export default Counter;

In this example, If we were to update the state directly using setCount(count + 1), it would not reliably use the latest state value. Instead, bypassing the callback to the setter (prevCount => prevCount + 1), React ensures that the state update is based on the most recent value of count. This helps prevent potential issues with state consistency and ensures that the component behaves as expected.

Use useState instead of variables

The first mistake often seen, even among experienced developers, is directly declaring variables within React components. Some individuals may not fully understand how React handles state comparison and re-rendering. It's crucial to avoid declaring state directly as a variable within a component, as doing so will redeclare the variable on every render cycle, preventing React from effectively memoizing values.

import AnotherComponent from 'components/AnotherComponent'

const Component = () => {
  // Don't do this.
  const value = { someKey: 'someValue' }

  return <AnotherComponent value={value} />
}

In this case, React won't memorize the state value, leading to a different JavaScript reference for value each render. Consequently, components depending on value, such as AnotherComponent, will unnecessarily re-render on every render cycle, resulting in wasted resources.

Instead, utilize React's useState hook to manage the state. By doing so, React maintains the same reference for the state value until it's updated with setValue. This allows React to accurately determine when to trigger effects and recalculate memoizations.

import { useState } from 'react'
import AnotherComponent from 'components/AnotherComponent'

const Component = () => {
  // Do this instead.
  const [value, setValue] = useState({ someKey: 'someValue' })

  return <AnotherComponent value={value} />
}

If a state is only needed for initialization and never updated, consider declaring the variable outside the component. In doing so, the JavaScript reference remains unchanged throughout the component's lifecycle.

// Do this if you never need to update the value.
const value = { someKey: 'someValue' }

const Component = () => {
  return <AnotherComponent value={value} />
}

Utilize memoization techniques to prevent unnecessary re-renders

Explore this article for more insights.

Use useCallback to prevent dependency changes

While useCallback can certainly help avoid function instantiations, its utility extends further to optimize the usage of other useCallback and memoization instances. By maintaining the same memory reference for the wrapped function between renders, useCallback enables efficient optimization strategies.

import { memo, useCallback, useMemo } from 'react'

const MemoizedChildComponent = memo({ onTriggerFn }) => {
  // Some component code...
})

const Component = ({ someProp }) => {
  // Reference to onTrigger function will only change when someProp does.
  const onTrigger = useCallback(() => {
    // Some code...
  }, [someProp])

  // This memoized value will only update when onTrigger function updates.
  // The value would be recalculated on every render if onTrigger wasn't wrapper in useCallback.
  const memoizedValue = useMemo(() => {
    // Some code...
  }, [onTrigger])

  // MemoizedChildComponent will only rerender when onTrigger function updates.
  // If onTrigger wasn't wrapped in a useCallback, MemoizedChildComponent would rerender every time this component renders.
  return (<>
    <MemoizedChildComponent onTriggerFn={onTrigger} />
    <button onClick={onTrigger}>Click me</button>
   </>)
}

Add an empty dependency list to useEffect when no dependencies are required

If you have an effect that isn't dependent on any variables, be sure to use an empty list as the second argument to useEffect. Failure to do so will cause the useEffect to run on every render.

import { useEffect } from 'react'

const Component = () => {
  useEffect(() => {
    // Some code.

    // Do not do this.
  })

  return <div>Example</div>
}
import { useEffect } from 'react'

const Component = () => {
  useEffect(() => {
    // Some code.

    // Do this.
  }, [])

  return <div>Example</div>
}

The same principle applies to other React hooks, such as useCallback and useMemo. However, as explained later in this article, you may not need to utilize those hooks at all if you lack any dependencies.

Always add all dependencies to useEffects and other React hooks

When dealing with dependency lists for built-in React hooks like useEffect and useCallback, always ensure to include all relevant dependencies in the dependency list (the second argument of the hook). Omitting a dependency can lead to the effect or callback using outdated values, often resulting in hard-to-detect bugs.

While omitting all variables may seem convenient, it can be a risky practice. Sometimes, you may not want an effect to run again if a specific value is updated. However, finding a suitable solution for this scenario not only helps prevent bugs but also leads to better-written code overall.

Moreover, it's crucial to consider the implications of leaving out dependencies to avoid bugs. Any oversight in dependency management may resurface when upgrading to newer React versions. For instance, in strict mode in React 18, updating hooks are triggered twice in development, and such behavior may manifest in production in future React versions.

import React, { useState, useEffect } from 'react';

function WeatherDisplay({ city, countryCode }) {
  const [weatherData, setWeatherData] = useState(null);
  const [loading, setLoading] = useState(true);

  // Fetch weather data when city or countryCode changes
  useEffect(() => {
    setLoading(true);
    fetchWeather(city, countryCode)
      .then(data => {
        setWeatherData(data);
        setLoading(false);
      })
      .catch(error => {
        console.error('Error fetching weather data:', error);
        setLoading(false);
      });
  }, [city, countryCode]); // <-- Dependency list includes city and countryCode

  return (
    <div>
      {loading ? (
        <p>Loading...</p>
      ) : weatherData ? (
        <div>
          <h2>Weather in {city}, {countryCode}</h2>
          <p>Temperature: {weatherData.temperature}°C</p>
          <p>Conditions: {weatherData.conditions}</p>
        </div>
      ) : (
        <p>No weather data available</p>
      )}
    </div>
  );
}

export default WeatherDisplay;

Do not use useEffect to initiate External code

Let's say you need to initialize a library. Often, I've observed the initialization code placed within a useEffect hook with an empty dependency list, which is unnecessary and prone to errors. If the initialization function isn't reliant on the component's internal state, it should be initialized outside of the component.

However, if the component's internal state is essential for initialization, you can include it within an effect. In such cases, it's crucial to add all the dependencies used in the initialization process to the dependency list of the useEffect, as discussed in the previous section. This ensures that the effect runs correctly and is triggered whenever any of its dependencies change.

import { useEffect } from 'react'
import initLibrary from '/libraries/initLibrary'

const Component = () => {
  // Do not do this.
  useEffect(() => {
    initLibrary()
  }, [])

  return <div>Example</div>
}
import initLibrary from '/libraries/initLibrary'

// Do this instead.
initLibrary()

const Component = () => {
  return <div>Example</div>
}

Do not use useMemo with empty dependencies

If you find yourself adding a useMemo hook with an empty dependency list, it's essential to question the reasoning behind it.

Is it because the memoization depends on a component's state variable, or are you unsure of what to include in the dependencies? In such cases, using useMemo may not be necessary and could potentially slow down your application. Instead, consider moving the memoization logic out of the component altogether, as it may not belong there.

import { useMemo } from 'react'

const Component = () => {
  // Do not do this.
  const memoizedValue = useMemo(() => {
    return 3 + 5
  }, [])

  return <div>{memoizedValue}</div>
}
// Do this instead.
const memoizedValue = 3 + 5

const Component = () => {
  return <div>{memoizedValue}</div>
}

Do not declare components within other components

I see this a lot, please stop doing it already.

const Component = () => {

  // Don't do this.
  const ChildComponent = () => {
    return <div>I'm a child component</div>
  }

  return <div><ChildComponent /></div>
}

What's the problem with it? Well, the main issue lies in misusing React. As we've discussed earlier, variables declared within a component are redeclared each time the component renders. Consequently, in this scenario, the functional child component has to be recreated every time the parent rerenders.

This poses several problems:

  1. A function instantiation occurs with every render.

  2. React lacks the ability to optimize component rendering effectively.

  3. If hooks are utilized in ChildComponent, they are reinitialized with each render.

  4. The component's readability diminishes, especially when there are numerous child components within a single React component.

So, what's the alternative? Simply declare the child component outside the parent component.

// Do this instead.
const ChildComponent = () => {
    return <div>I'm a child component</div>
}

const Component = () => {
  return <div><ChildComponent /></div>
}

Even better, place it in a separate file for improved organization and clarity.

// Do this instead.
import ChildComponent from 'components/ChildComponent'

const Component = () => {
  return <div><ChildComponent /></div>
}

Do not use hooks in if statements (no conditional hooks)

This one is explained in React's Documentation. One should never write conditional hooks, simply as that.

import { useState } from 'react'

const Component = ({ propValue }) => {
  if (!propValue) {
    // Don't do this.
    const [value, setValue] = useState(propValue)
  }

  return <div>{value}</div>
}

Do not use hooks in if statements (no conditional hooks)

If statements are conditional, it's best not to put hooks inside them, as React's Documentation explains. Another thing to watch out for is the "return" keyword, which can also cause conditional hook renders.

import { useState } from 'react'

const Component = ({ propValue }) => {

  if (!propValue) {
    return null
  }

  // This hook is conditional, since it will only be called if propValue exists.
  const [value, setValue] = useState(propValue)

  return <div>{value}</div>
}

In the example provided, a conditional return statement makes the following hook conditional. To prevent this, ensure all hooks are placed above the component's first conditional rendering. Alternatively, always place hooks at the top of the component to keep things simple.

import { useState } from 'react'

const Component = ({ propValue }) => {
  // Do this instead, place hooks before conditional renderings.
  const [value, setValue] = useState(propValue)

  if (!propValue) {
    return null
  }

  return <div>{value}</div>
}

Use useReducer instead of multiple useState

Probably one of the most frequently used hooks in React is useState . I have created and seen components over time that have got a lot of different states. So it's natural that they become flooded with a lot of useState hooks.

const CustomersMap = () => {
  const [isDataLoading, setIsDataLoading] = useState(false)
  const [customersData, setCustomersData] = useState([])
  const [hasError, setHasError] = useState(false)
  const [isHovered, setIsHovered] = useState(false)
  const [hasMapLoaded, setHasMapLoaded] = useState(false)
  const [mapData, setMapData] = useState({})
  const [formData, setFormData] = useState({})
  const [isBtnDisabled, setIsBtnDisabled] = useState(false)

  ...

  return ( ... )
}

Having a lot of different useState hooks is always a great sign that the size therefore the complexity of your component is growing.

If you can create some smaller sub-components where you can transfer some states and JSX in, then this is the great way to go. So you are cleaning up your useState hooks and your JSX in one step.

In our example above, we could put the last two states in a separate component that handles all states and JSX which has to do with a form.

But there are scenarios where this doesn't make sense, and you have to keep those many different states inside one component. To improve the legibility of your component, there is the useReducer hook.

The official React docs say this about it:

useReducer is usually preferable to useState when you have complex state logic that involves multiple sub-values ot when the next state depends on the previous one. useReducer also lets you optimize performace for components that trigger deep updates because you can pass dispatch down instead of callbacks.

With that in mind, the component would be like this when using useReducer:

// INITIAL STATE
const initialState = {
  isDataLoading: false,
  customerData: [],
  hasError: false,
  isHovered: false,
  hasMapLoaded: false,
  mapData: {},
  formdata: {},
  isBtnDisabled: false
}

// REDUCER
const reducer = (state, action) => {
  switch (action.type) {
    case 'POPULATE_CUSTOMER_DATA':
      return {
        ...state,
        customerData: action.payload
      }
    case 'LOAD_MAP':
      return {
        ...state,
        hasMapLoaded: true
      }
    ...
    ...
    ...
    default: {
      return state
    }    
  }
}

// COMPONENT
const CustomersMap = () => {
  const [state, dispatch] = useReducer(reducer, initialState)

  ...

  return ( ... )
}

The component itself looks cleaner and comes along with some great benefits as you can see inside the docs. If you are used to Redux, the concept of a reducer and how it is built isn't new to you.

My personal rule is to implement the useReducer hook if my component exceeds four useState hooks, or if the state itself is more complex than just a boolean, for example. It might be an object for a form with some deeper levels inside.

Write initial states as functions rather than objects

Note the code from the current tip. Look at the getInitialFormState function.

// Code removed for brevity.

// Initial state is a function here.
const getInitialFormState = () => ({
  text: '',
  error: '',
  touched: false
})

const formReducer = (state, action) => {
  // Code removed for brevity.
}

const Component = () => {
  const [state, dispatch] = useReducer(formReducer, getInitialFormState());
  // Code removed for brevity.
}

See that I wrote the initial state as a function. I could rather have used an object directly.

// Code removed for brevity.

// Initial state is an object here.
const initialFormState = {
  text: '',
  error: '',
  touched: false
}

const formReducer = (state, action) => {
  // Code removed for brevity.
}

const Component = () => {
  const [state, dispatch] = useReducer(formReducer, initialFormState);
  // Code removed for brevity.
}

You'll notice that I defined the initial state as a function instead of using an object directly. Why did I choose to do this? The reason is simple: to avoid mutability.

If getInitialFormState returns an object directly, there's a risk of unintentionally mutating that object elsewhere in our code. In such a case, we might not get the initial state back when using the variable again, such as when resetting the form. Instead, we could end up with a mutated object that, for example, touched has been set to true.

This issue can also arise during unit testing. If multiple tests manipulate getInitialFormState, each test might work fine when run individually. However, some tests may fail when all tests are run together in a test suite due to unexpected mutations.

To address this, it's a good practice to define initial states as getter functions that return the initial state object. Alternatively, you can use libraries like Immer, which help avoid writing mutable code.

Use useRef instead of useState when a component should not rerender

Using useRef instead of useState when a component should not re-render is advantageous because it helps in scenarios when you need to store mutable values across renders without causing the component to re-render unnecessarily.

Here is an example to illustrate this:

Suppose you have a component that displays a timer, and you want to update the timer every second without triggering a re-render of the component.

import React, { useState, useEffect } from 'react';

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setSeconds(prevSeconds => prevSeconds + 1);
    }, 1000);

    return () => clearInterval(intervalId);
  }, []);

  return (
    <div>
      <p>Seconds: {seconds}</p>
    </div>
  );
}

In this example, the component re-renders every time the state (seconds) changes, even though the UI doesn't change visually. This can lead to unnecessary re-renders and affect performance.

import React, { useRef, useEffect } from 'react';

function Timer() {
  const secondsRef = useRef(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      secondsRef.current += 1;
      console.log('Seconds:', secondsRef.current); // Access the current value without re-rendering
    }, 1000);

    return () => clearInterval(intervalId);
  }, []);

  return (
    <div>
      <p>Seconds: {secondsRef.current}</p>
    </div>
  );
}

In this example, we use useRef to store the timer value(seconds) without causing re-renders. The component remains unaffected by changes to the timer value, improving performance by avoiding unnecessary re-renders.

In the case above, you may wonder why we need to use a useRef at all. why can't we simply use a variable outside of the component?

// This does not work the same way!
const triggered = false

const Component = () => {
  useEffect(() => {
    if (!triggered) {
      triggered = true

      // Some code to run here...
    }
  }, [])
}

The reason we need to use useRef is because the above code doesn't work in the same way! The above triggered variable will only be false once. If the component unmounts, the variable triggered will still be set to true when the component mounts again, because the triggered variable is not bound to React's life cycle.

When useRef is used, React will reset its value when a component unmounts and mounts again. In this case, we probably want to use useRef, but in other cases, a variable outside the component may be what we are searching for.

Overall, using useRef in situations where you need to store mutable values without triggering re-renders is a recommended practice for optimizing React components.

Use linting for code quality

Unitizing a linter tool, such as ESLint, can greatly improve code quality and consistency in your React project.

By using linter, you can:

  • Enforces coding standards: This ensures that all developers follow the same guidelines, leading to more uniform and readable codebases.

  • Identifies potential errors: Linters can detect common programming errors, such as syntax errors, undefined variables, and unused variables, before the runtime issues.

  • Promotes code consistency: Linting helps maintain consistent coding style and patterns throughout the project.

  • Improves code maintainability: By enforcing coding standards and identifying potential issues, linting contributes to the overall maintainability of the codebase.

  • Enhances developer productivity: Linting provides immediate feedback to developers as they write code, highlighting issues and suggesting improvements in real-time. This enables developers to address issues quickly and focus on writing quality code, ultimately improving productivity.

Avoid default export

The problem with default export is that it can make it harder to understand which components are being imported and used in other files. It also limits the flexibility of imports, as default imports can only have a single default export per file.

// ❌ Avoid default export
const Todo = () => {
  // component logic...
};

export default Todo;

Instead, It's recommended to use named exports in React:

// ✅ Use named export
const Todo = () => {

}

export { Todo };

Using named exports provides better clarity when importing components, making the codebase more organized and easier to navigate.

  • Named exports work well with tree sharking.
    Tree sharking is a term commonly used within a Javascript context to describe the removal of dead code. It relies on the import and export statements to detect if code modules are exported and imported for use between Javascript files.

  • Refactoring becomes easier.

  • Easier to identify and understand the dependencies of the module.

Use object destructing

When you use direct property access using dot notation for accessing individual properties of an object, it will work fine for simple cases.

// ❌ Avoid direct property access using dot notation
const todo = {
   id: 1,
   name: "Morning Task",
   completed: false
}

const id = todo.id;
const name = todo.name;
const completed = todo.completed;

This approach can work fine for simple cases, and it can become difficult and repetitive when dealing with larger objects or when only a subset of properties is needed.

Object destructing, on the other hand, provides a more concise and elegant way to extract object properties. It allows you to destructure an object in a single line of code and assign multiple properties to variables using a syntax similar to object literal notation.

// ✅ Use object destructuring
const { id, name = "Task", completed } = todo;
  • It reduces the need for repetitive object property access

  • Supports the assignment of default values.

  • Allows variable renaming and aliasing.

Prefer passing objects instead of multiple props

When we use multiple arguments or props are used to pass user-related information to components or functions, it can be challenging to remember the order and purpose of each argument, especially as the number of arguments grows.

// ❌ Avoid passing multiple arguments
const updateTodo = (id, name, completed) => {
 //...
}

// ❌ Avoid passing multiple props
const TodoItem = (id, name, completed) => {
  return(
    //...
  )
}

When the number of arguments increases, it becomes more challenging to maintain or refactor the code. There is an increased chance of making mistakes, such as omitting an argument or providing incorrect value.

// ✅ Use object arguments
const updateTodo = (todo) => {
 //...
}

const todo = {
   id: 1,
   name: "Morning Task",
   completed: false
}

updateTodo(todo);
  • Function becomes more self-descriptive and easier to understand.

  • Reducing the chance of errors caused by incorrect argument order.

  • Easy to add and modify properties without changing the function signature.

  • Simplify the process of debugging and testing functions to pass an object as the argument.

Use enums instead of numbers of strings

// ❌ Avoid Using numbers or strings
switch(status) {
  case 1:
    return //...
  case 2:
    return //...
  case 3:
    return //...
}

The above code is harder to understand and maintain, as the meaning of the number may not be immediately clear.

// ✅ Use Enums
const Status = {
  NOT_STARTED: 1,
  IN_PROGRESS: 2,
  COMPLETED: 3
}

const { NOT_STARTED, IN_PROGRESS COMPLETED } = Status;

switch(status) {
  case NOT_STARTED:
    return //...
  case IN_PROGRESS:
    return //...
  case COMPLETED:
    return //...
}
  • Enums have meaningful and self-descriptive values.

  • Improve code readability.

  • Reducing the chance of typos or incorrect values.

  • Better type checking, editor autocompletion, and documentation.

Maintain a structured import order

If you have already had some experience in React, you might have seen files that are bloated with a lot of import statements. They might also be missed with external imports from third-party packages and internal imports like other components, util functions, styles, and many more.

Real World Example-cut (In reality the imports span over 55 lines.):

import React, { useState, useEffect, useCallback } from "react";
import Typography from "@material-ui/core/Typography";
import Divider from "@material-ui/core/Divider";
import Title from "../components/Title";
import Navigation from "../components/Navigation";
import DialogActions from "@material-ui/core/DialogActions"
import { getServiceURL } from "../../utils/getServiceURL";
import Grid from "@material-ui/core/Grid";
import Paragraph from "../components/Paragprah";
import { sectionTitleEnum } from "../../constants";
import { useSelector, useDispatch } from "react-redux";
import Box from "@material-ui/core/Box";
import axios from 'axios';
import { DatePicker } from "@material-ui/pickers";
import { Formik } from "formik";
import CustomButton from "../components/CustomButton";

You probably recognize the deal here. It is difficult to distinguish what all the third-party and the local(internal) imports. They are not grouped and seem to be all over the place.

Better version:

import React, { useState, useEffect, useCallback } from "react";
import { useSelector, useDispatch } from "react-redux";
import { Formik } from "formik";
import axios from 'axios';
import Typography from "@material-ui/core/Typography";
import Divider from "@material-ui/core/Divider";
import Box from "@material-ui/core/Box";
import DialogActions from "@material-ui/core/DialogActions";
import Grid from "@material-ui/core/Grid";
import { DatePicker } from "@material-ui/pickers";

import { getServiceURL } from "../../utils/getServiceURL";
import { sectionTitleEnum } from "../../constants";
import CustomButton from "../components/CustomButton";
import Title from "../components/Title";
import Navigation from "../components/Navigation";
import Paragraph from "../components/Paragraph";

The structure is clearer and it's very easy to distinguish where the external and internal imports are. Of course, you can optimize it more if you are using more named imports (if that is possible). That allows you to import all the components that are coming from ui-material all on one line.

I have never seen other developers who like to split the import structure up into three different parts:

Built-in (like React) --> External (third-party node modules) --> Internal.

You can manage it every time by yourself or let a linter do the job. Here's a great article about how to configure your linter for your React app to maintain proper import structure.

Test your code

I know testing is likely not your favorite task as a developer. I used to be like that. In the beginning, it seemed to be a necessary and disturbing task. This might be true in the short run. But in the long run - and when the application grows - it is vital.

For me, testing has become a practice that ensures I am doing my job more professionally and delivering higher-quality software.

Basically, there is nothing wrong with manual testing by a human and that shouldn't be avoided completely. But imagine you are integrating a new feature and want to make sure that nothing is broken. This can be a time-consuming task and is prone to human error.

During the time you are writing tests, you are already in the thinking process of how to organize your code in order to pass this test. For me, this is always helpful because I recognize what pitfalls might arise and that I have to keep an eye on them.

You are not directly jumping into writing your code either(which I wouldn't recommend at all), but you are thinking first about the goal.

For example "what should that particular component do? what important edge cases might arise that I have to test? Can I make the component more pure so that it only serves one purpose?..."

Having a vision for the code you are about to write also helps you to maintain a sharp focus on serving that vision.

Tests can also serve as a kind of documentation because, for a new developer who is new to the code base, it can be very helpful to understand the different parts of the software and how it's expected to work.

So, don't avoid testing because it seems to be extra work. The reality is that it can save you extra work in the future when you set it up properly.

Take a look at the "Testing" chapter inside the React Docs, go through a few tutorials on testing in React, and just start writing your first small TDD application or implement tests into an app you are currently working on.

Extract reusable logic into custom hooks

According to the React docs,

Hooks allow us to reuse stateful logic without changing out component hierarchy.

Basically, they are a better solution to the techniques, that were used before in combination with class components. If you have been coding for a while, you might remember the use of High Order Components or render props.

Whenever you find yourself in a situation where you have to use the same stateful logic that is already used in another function component, that is a great time to create a custom hook. Inside it, you encapsulate the logic and just have to call the hook as a function inside your components.

Let's take a look at a quick example where we need to update our UI according to the screen size and want to keep track of the current window size when resizing the browser window manually.

const ScreenDimensions = () => {
  const [windowSize, setWindowSize] = useState({
    width: undefined,
    height: undefined,
  });

  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }
    window.addEventListener('resize', handleResize);
    handleResize();
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return (
      <>
        <p>Current screen width: {windowSize.width}</p>
        <p>Current screen height: {windowSize.height}</p>
    </>
  )
}

As you can see, the solution is pretty straightforward and there is nothing wrong with defining it like this.

Now comes the tricky part. Imagine we would like to use the exact logic in another component, where we will render the different UI(one for smartphones and one for desktops) based on the current screen size.

Of course, we could just copy the logic, paste it and we are done. But this is not good practice, as you might know the DRY principle.

If we would like to adjust the logic, we have to do it in both components. And when we paste our logic into even more components, it becomes less maintainable and more error-prone.

So, What would you normally do in the vanilla javascript project? you would most likely define a function that encapsulates the logic and can be used in many different places. That is exactly what we will achieve with hooks. They are nothing more than Javascript functions but with some React specialties because they are using React hooks.

Let's see how our custom hook would look:

const useWindowSize = () => {
  const [windowSize, setWindowSize] = useState({
    width: undefined,
    height: undefined,
  });

  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }
    window.addEventListener('resize', handleResize);
    handleResize();
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return windowSize;

Now let's simply call it inside our ScreenDimentions component:

const ScreenDimensions = () => {
  const windowSize = useWindowSize()

  return (
      <>
        <p>Current screen width: {windowSize.width}</p>
        <p>Current screen height: {windowSize.height}</p>
    </>
  )

Handle errors effectively

Handle errors effectively is often overlooked and underestimated by many developers. Like many other best practices this seems to be an afterthought at the beginning. You want to make the code work and don't want to "waste" time thinking much about errors.

But once you have become more experienced and have been in nasty situations where better error handling could have saved you a lot of energy (and valuable time of course), you realize that it's mandatory in the long run to have solid error handling in your application. Especially when the application is deployed to production.

React Error Boundary

This is a custom class component that is used as a wrapper of your entire application. Of course, you can wrap the ErrorBoundary component also around components that are deeper in the component tree to render more specific UI, for example. Basically, it's also the best practice to wrap the ErroBoundary around a component that is error-prone.

With the lifecycle method componentDidCatch you are able to catch errors during the rendering phase or any other lifecycles of the child components. So when an error arises during that phase, it bubbles up and gets caught by the ErrorBoundary component.

If you are using a logging service(which I also highly recommend), this is a great place to connect to it.

The static function getDerivedStateFromError is called during the render phase and is used to update the state of your ErrorBoudary component. Based on your state, you can conditionally render an error UI.

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    //log the error to an error reporting service
    errorService.log({ error, errorInfo });
  }

  render() {
    if (this.state.hasError) {
      return <h1>Oops, something went wrong.</h1>;
    }
    return this.props.children; 
  }
}

A big drawback of this approach is that it doesn't handle errors in asynchronous callbacks, server-side rendering, or event handlers because they are outside of the boundary.

Use try-catch to handle errors beyond boundaries

This technical is effective in catching errors that might occur inside asynchronous callbacks. Let's imagine we are fetching a user's profile data from an API and want to display it inside a Profile Component.

const UserProfile = ({ userId }) => {
    const [isLoading, setIsLoading] = useState(true)
    const [profileData, setProfileData] = useState({})

    useEffect(() => {
        // Separate function to make of use of async
        const getUserDataAsync = async () => {
            try {
                // Fetch user data from API
                const userData = await axios.get(`/users/${userId}`)
                // Throw error if user data is falsy (will be caught by catch)
                if (!userData) {
                    throw new Error("No user data found")
                }
                // If user data is truthy update state
                setProfileData(userData.profile)
            } catch(error) {
                // Log any caught error in the logging service
                errorService.log({ error })
                // Update state 
                setProfileData(null)
            } finally {
                // Reset loading state in any case
                setIsLoading(false)
            }
        }

        getUserDataAsync()
    }, [])

    if (isLoading) {
        return <div>Loading ...</div>
    }

    if (!profileData) {
        return <ErrorUI />
    }

    return (
        <div>
            ...User Profile
        </div>
    )
}

When the component gets mounted, it starts a GET request to our API to receive the user data for the corresponding userId that we will get from the props.

Using try-catch helps us catch any errors that might occur during that API call. For example, that could be a 404 or a 500 response from the API.

Once an error gets caught, we are inside that catch block and receive the error as a parameter. Now we are able to log it in our logging service and update the state according to display a custom error UI.

Use the react-error-boundary library (personal recommendation)

This library basically melts those two techniques from above together. It simplifies error handling in React and overcomes the limitations of the ErrorBoundary that we have seen above.

import { ErrorBoundary } from 'react-error-boundary'

const ErrorComponent = ({ error, resetErrorBoundary }) => {

  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre>{error.message}</pre>
    </div>
  )
}

const App = () => {
  const logError = (error, errorInfo) => {
      errorService.log({ error, errorInfo })
  }


  return (
    <ErrorBoundary 
       FallbackComponent={ErrorComponent}
       onError={logError}
    >
       <MyErrorProneComponent />
    </ErrorBoundary>
  );
}

This library exports a component that is made up of the ErrorBoundary functionality we already know and adds some nuances to it. It allows you to pass a FallbackComponent as a prop that should be rendered once an error gets caught.

It also exposes a prop onError that provides a callback function when an error arises. It's great for using it to log the error to a logging service.

There are some other props that are quite useful. if you would like to know more fell free to check out the docs.

This library also provides a hook called userErrorHandler() this is meant to catch any errors that are outside the boundaries like event-handlers, in asynchronous code, and in server-side rendering.

Avoid magic numbers

The concept of "magic numbers" in programming refers to the use of hard code numbers in your code without an explanatory context or name. These numbers are called "magic" because their meaning isn't clear without additional context, making the code harder to understand and maintain. Replace magic numbers with named constants not only clarifies their purpose but also simplifies future modifications and increases the code readability.

There are a few reasons why you should avoid magic numbers, but the biggest would be for readability.

Let's say we have this code:

if age >= 21:
    # Do something

It is not clear what 21 stands for. Why does the age have to be greater than 21 to do something? Also age of what, a person?

In the United States, the drinking age is 21. I know this differs from country to country but I want to make sure we are on the same page about the code coming up.

legal_drinking_age = 21
if user_age >= legal_drinking_age:
    # Cheers!

There is 0 confusion with this code. The legal drinking age is 21 and when the user_age is greater than or equal to 21 they can drink. Make sense and is easy to read.

In the improved version:

  • Self-explanatory code: The variable legal_drinking_age indicates that 21 is the age threshold for legal drinking. It acts as self-documenting code, making comments unnecessary for explaining the number's significance.

  • Easy to update: if the legal drinking is changed, you only need to update the legal_drinking_age constant, and the changes reflect whatever it's used.

  • Reduces Errors: By using a named constant, you reduce the risk of typos and incorrect updates. Changing one occurrence of 21 to 18 but missing others could introduce bugs. With a named constants, this risk is mitigated.

Follow common naming conventions

Let's explore here.