React Best Practices Handbook - Part I

Tuan Tran VanTuan Tran Van
49 min read

Table of contents

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.

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. It makes 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.

When setting default values for props, do it while destructing them

❌ Bad: You may need to define the defaults in multiple places and introduce new variables.

function Button({ onClick, text, small, colorScheme }) {
  let scheme = colorScheme || "light";
  let isSmall = small || false;
  return (
    <button
      onClick={onClick}
      style={{
        color: scheme === "dark" ? "white" : "black",
        fontSize: isSmall ? "12px" : "16px",
      }}
    >
      {text ?? "Click here"}
    </button>
  );
}

Good: You can set all your defaults in one place at the top. This makes it easy for someone to locate them.

function Button({
  onClick,
  text = "Click here",
  small = false,
  colorScheme = "light",
}) {
  return (
    <button
      onClick={onClick}
      style={{
        color: colorScheme === "dark" ? "white" : "black",
        fontSize: small ? "12px" : "16px",
      }}
    >
      {text}
    </button>
  );
}

Ensure that the value is a boolean before using value && <Component {...props}/> it to prevent results from being displayed on the screen.

Bad: When the list is empty, 0 will be printed on the screen.

export function ListWrapper({ items, selectedItem, setSelectedItem }) {
  return (
    <div className="list">
      {items.length && ( // `0` if the list is empty
        <List
          items={items}
          onSelectItem={setSelectedItem}
          selectedItem={selectedItem}
        />
      )}
    </div>
  );
}

Good: There are nothing will be printed on the screen when there are no items.

export function ListWrapper({ items, selectedItem, setSelectedItem }) {
  return (
    <div className="list">
      {items.length > 0 && (
        <List
          items={items}
          onSelectItem={setSelectedItem}
          selectedItem={selectedItem}
        />
      )}
    </div>
  );
}

Use functions (inline or not) to avoid polluting your scope with intermediate variables

Bad: The variables gradeSum and gradeCount are cluttering the component’s scope.

function Grade({ grades }) {
  if (grades.length === 0) {
    return <>No grades available.</>;
  }

  let gradeSum = 0;
  let gradeCount = 0;

  grades.forEach((grade) => {
    gradeCount++;
    gradeSum += grade;
  });

  const averageGrade = gradeSum / gradeCount;

  return <>Average Grade: {averageGrade}</>;
}

Good: The variables gradeSum and gradeCountare scoped within computeAverageGrade function.

function Grade({ grades }) {
  if (grades.length === 0) {
    return <>No grades available.</>;
  }

  const computeAverageGrade = () => {
    let gradeSum = 0;
    let gradeCount = 0;
    grades.forEach((grade) => {
      gradeCount++;
      gradeSum += grade;
    });
    return gradeSum / gradeCount;
  };

  return <>Average Grade: {computeAverageGrade()}</>;
}

Note: you can also define a function computeAverageGrade outside the component and call it inside.

Move the data that doesn’t rely on the component props/state outside of it for cleaner (and more efficient) code.

❌ Bad: OPTIONS and renderOption don’t need to be inside the component because they don’t depend on any props or state. Also, keeping them inside means we get new object references every time the component renders. if we were to pass renderOption to a child component wrapped in memo, it would break the memorization.

function CoursesSelector() {
  const OPTIONS = ["Maths", "Literature", "History"];
  const renderOption = (option: string) => {
    return <option>{option}</option>;
  };

  return (
    <select>
      {OPTIONS.map((opt) => (
        <Fragment key={opt}>{renderOption(opt)}</Fragment>
      ))}
    </select>
  );
}

✅ Good: Move them out of the component to keep the component clean and references stable.

const OPTIONS = ["Maths", "Literature", "History"];
const renderOption = (option: string) => {
  return <option>{option}</option>;
};

function CoursesSelector() {
  return (
    <select>
      {OPTIONS.map((opt) => (
        <Fragment key={opt}>{renderOption(opt)}</Fragment>
      ))}
    </select>
  );
}

If you frequently check a prop’s value before something, introduce a new component.

❌ Bad: The code is cluttered because of all the user == null checks. We can’t return early because of the rules of hooks.

function Posts({ user }) {
  // Due to the rules of hooks, `posts` and `handlePostSelect` must be declared before the `if` statement.
  const posts = useMemo(() => {
    if (user == null) {
      return [];
    }
    return getUserPosts(user.id);
  }, [user]);

  const handlePostSelect = useCallback(
    (postId) => {
      if (user == null) {
        return;
      }
      // TODO: Do something
    },
    [user]
  );

  if (user == null) {
    return null;
  }

  return (
    <div>
      {posts.map((post) => (
        <button key={post.id} onClick={() => handlePostSelect(post.id)}>
          {post.title}
        </button>
      ))}
    </div>
  );
}

✅ Good: We introduce a new component, UserPosts, that takes a defined user and is much cleaner.

function Posts({ user }) {
  if (user == null) {
    return null;
  }

  return <UserPosts user={user} />;
}

function UserPosts({ user }) {
  const posts = useMemo(() => getUserPosts(user.id), [user.id]);

  const handlePostSelect = useCallback(
    (postId) => {
      // TODO: Do something
    },
    [user]
  );

  return (
    <div>
      {posts.map((post) => (
        <button key={post.id} onClick={() => handlePostSelect(post.id)}>
          {post.title}
        </button>
      ))}
    </div>
  );
}

Use the CSS :empty pseudo-class to hide elements with no children.

In the example below 👇, a wrapper takes children and adds a red border around them:

function PostWrapper({ children }) {
  return <div className="posts-wrapper">{children}</div>;
}
.posts-wrapper {
  border: solid 1px red;
}

❌ Problem: The border remains visible on the screen even if the children are empty (i.e., equal to null, undefined, etc.).

✅ Solution: Use the :empty CSS pseudo-class to ensure the wrapper is not displayed when it’s empty.

.posts-wrapper:empty {
  display: none;
}

When dealing with different cases, use value === case && <Component /> to avoid holding onto the old state.

❌ Problem: In this sanbox, the counter doesn't reset when switching between Posts and Snippets. This happens because when rendering the same component, its states persist across type changes.

✅ Solution: Render a component based on the selectedType or use a key to force a reset when the type changes.

function App() {
  const [selectedType, setSelectedType] = useState<ResourceType>("posts");
  return (
    <>
      <Navbar selectedType={selectedType} onSelectType={setSelectedType} />
      {selectedType === "posts" && <Resource type="posts" />}
      {selectedType === "snippets" && <Resource type="snippets" />}
    </>
  );
}

// We use the `selectedType` as a key
function App() {
  const [selectedType, setSelectedType] = useState<ResourceType>("posts");
  return (
    <>
      <Navbar selectedType={selectedType} onSelectType={setSelectedType} />
      <Resource type={selectedType} key={selectedType} />
    </>
  );
}

Use crypto.randomUUID or Math.random to generate keys

JSX elements inside a map() call always need keys.

Suppose your elements don't already have keys. In that case, you can generate unique IDs using crypto.randomUUID, Math.random, or the uuid library.
Note: Beware that crypto.randomUUID is not defined in older browsers.

Strategically use the key attribute to trigger component re-renders

Wanna force a component to re-render from the scratch? Just change its key.
In the example below, we use this trick to reset the error boundary when switching to a new tab.

type ResourceType = 'posts' | 'snippets';

export function App() {
  const [selectedType, setSelectedType] = useState<ResourceType>('posts');
  return (
    <>
      <Navbar selectedType={selectedType} onSelectType={setSelectedType} />
      <ErrorBoundary
        fallback={<div>Something went wrong</div>}
        key={selectedType} // Without this key, an error will also be rendered when the resource type is `snippets`
      >
        <ResourceWithError type={selectedType} />
      </ErrorBoundary>
    </>
  );
}

function ResourceWithError({ type }: { type: ResourceType }) {
  const [likes, setLikes] = useState(0);

  const handleClick = () => {
    setLikes((prevLikes) => prevLikes + 1);
  };

  useEffect(() => {
    if (type === 'posts') {
      throw new Error('Posts are not valid');
    }
  }, []);

  return (
    <>
      <button onClick={handleClick}>
        Your {type == 'posts' ? 'posts' : 'snippets'} have {likes} like
        {likes == 1 ? '' : 's'}
      </button>
    </>
  );
}

Use a ref callback function for tasks such as monitoring size changes and managing multiple node elements.

Did you know that you can pass a function to the ref attribute instead of a ref object?

Here’s how it works.

  • When the DOM node is added to the screen, React calls the function with the DOM node as the argument.

  • When the DOM node is removed, React calls the function with null.

In the example above, we use this tip to skip the useEffect.

❌ Before: Using useEffect to focus the input

function App() {
  const ref = useRef();

  useEffect(() => {
    ref.current?.focus();
  }, []);

  return <input ref={ref} type="text" />;
}

✅ After: We focus on the input as soon as it is available.

function App() {
  const ref = useCallback((inputNode) => {
    inputNode?.focus();
  }, []);

  return <input ref={ref} type="text" />;
}

Keep the state at the lowest level necessary to minimize re-renders

Whenever the state changes inside a component, React re-renders the component and all its children (there is an exception with children wrapped in memo).

This happens even if those children don't use the changed state. To minimize re-renders, move the state down the component tree as far as possible.

❌ Bad: When sortOrder changes, both LeftSidebar and RightSidebar re-render.

function App() {
  const [sortOrder, setSortOrder] = useState("popular");
  return (
    <div className="App">
      <LeftSidebar />
      <Main sortOrder={sortOrder} setSortOrder={setSortOrder} />
      <RightSidebar />
    </div>
  );
}

function Main({ sortOrder, setSortOrder }) {
  return (
    <div>
      <Button
        onClick={() => setSortOrder("popular")}
        active={sortOrder === "popular"}
      >
        Popular
      </Button>
      <Button
        onClick={() => setSortOrder("latest")}
        active={sortOrder === "latest"}
      >
        Latest
      </Button>
    </div>
  );
}

✅ Good: sortOrder change will only affect Main.

function App() {
  return (
    <div className="App">
      <LeftSidebar />
      <Main />
      <RightSidebar />
    </div>
  );
}

function Main() {
  const [sortOrder, setSortOrder] = useState("popular");
  return (
    <div>
      <Button
        onClick={() => setSortOrder("popular")}
        active={sortOrder === "popular"}
      >
        Popular
      </Button>
      <Button
        onClick={() => setSortOrder("latest")}
        active={sortOrder === "latest"}
      >
        Latest
      </Button>
    </div>
  );
}

Throttle your network to simulate a slow network

Did you know you can simulate a slow internet connection directly in Chrome?

This is especially useful when:

  • Customers report slow loading times that you can't replicate on your faster network.

  • You're implementing lazy loading and want to observe how files load under slower conditions to ensure appropriate loading states.

Use StrictMode to catch bugs in your components before deploying them to production.

Using StrictMode is a proactive way to detect potential issues in your application during development.

It helps identify problems such as:

  • Incomplete cleanup in effects, like forgetting to release resources.

  • Impurities in React components ensure they return consistent JSX given the same inputs (props, state, and context).

React DevTools Components: Highlight components that render to identify potential issues.

I will use this trick whenever I suspect that my app has performance issues. You can highlight the components that render to detect potential problems (e.g., too many renders).

The gif below shows that the FollowersListFn component re-renders whenever the time changes, which is wrong.

Hide logs during the second render in Strict Mode

StrictMode helps catch bugs early in your application's development.

However, since it causes the component to render twice, this can result in duplicated logs, which might clutter your console.

You can hide logs during the second render in Strict Mode to address this:
Check out how to do it in the gif below:

Use ref to preserve values across re-renders

If you have a mutable value in your React application that isn’t stored in the state, you will notice that changes to these values don’t persist through re-renders.

This happens unless you save them globally.

You might consider putting these values in the state. However, if they are irrelevant to the rendering, doing so can cause unnecessary re-renders, which. wastes performance.

This is where useRef also shines.

In the example below, I want to stop the timer when the user clicks on some buttons. For that, I need to store intervalID somewhere.
❌ Bad: The example won’t work as intended because intervalid gets reset with every component re-render.

function Timer() {
  const [time, setTime] = useState(new Date());
  let intervalId;

  useEffect(() => {
    intervalId = setInterval(() => {
      setTime(new Date());
    }, 1_000);
    return () => clearInterval(intervalId);
  }, []);

  const stopTimer = () => {
    intervalId && clearInterval(intervalId);
  };

  return (
    <>
      <>Current time: {time.toLocaleTimeString()} </>
      <button onClick={stopTimer}>Stop timer</button>
    </>
  );
}

✅ Good: By using useRef, we ensure that the intervalId is preserved between renders.

function Timer() {
  const [time, setTime] = useState(new Date());
  const intervalIdRef = useRef();
  const intervalId = intervalIdRef.current;

  useEffect(() => {
    const interval = setInterval(() => {
      setTime(new Date());
    }, 1_000);
    intervalIdRef.current = interval;
    return () => clearInterval(interval);
  }, []);

  const stopTimer = () => {
    intervalId && clearInterval(intervalId);
  };

  return (
    <>
      <>Current time: {time.toLocaleTimeString()} </>
      <button onClick={stopTimer}>Stop timer</button>
    </>
  );
}

Prefer named functions over arrow functions within hooks such as UseEffect to easily find them in React Dev Tools.

if you have many hooks, finding them in React Dev Tools can be challenging.

One trick is to use named functions so you can quickly spot them.

❌ Bad: It’s hard to find the specific effect between many hooks

function HelloWorld() {
  useEffect(() => {
    console.log("🚀 ~ Hello, I just got mounted")
  }, []);

  return <>Hello World</>;
}

✅ Good: You can quickly spot the effect.

function HelloWorld() {
  useEffect(function logOnMount() {
    console.log("🚀 ~ Hello, I just got mounted");
  }, []);

  return <>Hello World</>;
}

Encapsulate logic with custom hooks

Let’s say I have a component that gets the theme from the user’s dark mode preferences and uses it inside the app.
It’s better to extract the logic that returns the theme into a custom hook(to reuse it and keep the component clean)

❌ Bad: App is overcrowded

function App() {
  const [theme, setTheme] = useState("light");

  useEffect(() => {
    const dqMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
    setTheme(dqMediaQuery.matches ? "dark" : "light");
    const listener = (event) => {
      setTheme(event.matches ? "dark" : "light");
    };
    dqMediaQuery.addEventListener("change", listener);
    return () => {
      dqMediaQuery.removeEventListener("change", listener);
    };
  }, []);

  return (
    <div className={`App ${theme === "dark" ? "dark" : ""}`}>Hello Word</div>
  );
}

✅ Good: App is much simpler, and we can reuse the logic

function App() {
  const theme = useTheme();

  return (
    <div className={`App ${theme === "dark" ? "dark" : ""}`}>Hello Word</div>
  );
}

// Custom hook that can be reused
function useTheme() {
  const [theme, setTheme] = useState("light");

  useEffect(() => {
    const dqMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
    setTheme(dqMediaQuery.matches ? "dark" : "light");
    const listener = (event) => {
      setTheme(event.matches ? "dark" : "light");
    };
    dqMediaQuery.addEventListener("change", listener);
    return () => {
      dqMediaQuery.removeEventListener("change", listener);
    };
  }, []);

  return theme;
}

Prefer functions over custom hooks

Never put logic inside a hook when a function can be used.
Hooks can only be used inside other hooks or components, whereas functions can be used everywhere.
Functions are simpler than hooks

Functions are easier to test.
❌ Bad: The useLocale hook is unnecessary since it doesn't need to be a hook. It doesn't use other hooks like useEffect, useState, etc.

function App() {
  const locale = useLocale();
  return (
    <div className="App">
      <IntlProvider locale={locale}>
        <BlogPost post={EXAMPLE_POST} />
      </IntlProvider>
    </div>
  );
}

function useLocale() {
  return window.navigator.languages?.[0] ?? window.navigator.language;
}

✅ Good: Create a function getLocale instead

function App() {
  const locale = getLocale();
  return (
    <div className="App">
      <IntlProvider locale={locale}>
        <BlogPost post={EXAMPLE_POST} />
      </IntlProvider>
    </div>
  );
}

function getLocale() {
  return window.navigator.languages?.[0] ?? window.navigator.language;
}

Prevent visual UI glitches by using the useLayoutEffect hook

When an effect isn’t caused by user interaction, the user will see the UI before the effect runs (often briefly)

As a result, if the effect modifies the UI, the user will see the initial UI version very quickly before seeing the updated one, creating a visual glitch.
Using useLayoutEffect ensures the effect runs synchronously after all DOM mutations, preventing the initial render glitch.

In this sandbox, we want the width to be equally distributed between the columns (I know this can be done in CSS, but I need an example).
With useEffect, you can see briefly at the beginning that the table is changing. The columns are rendered with their default size before being adjusted to their correct size.

If you are looking at another great usage, check out this post.

Generate unique IDs for accessibility attributes with the useId hook.

Tired of coming up with IDs or having them clash?
You can use the useId hook to generate a unique ID inside your React component and ensure your app is accessible.

function Form() {
  const id = useId();
  return (
    <div className="App">
      <div>
        <label>
          Name{" "}
          <input type="text" aria-describedby={id} />
        </label>
      </div>
      <span id={id}>Make sure to include full name</span>
    </div>
  );
}

Use the useSyncExternalStore to subscribe to an external store

This is rarely needed but this is a powerful hook.
Use this hook if:

  • You have some state not accessible in the React tree (i.e., not present in the state or context)

  • The state can change, and you need your component to be notified of changes

In the example below, I want a Logger singleton to log errors, warnings, info, etc. , in my entire app.

These are the requirements:

  • I need to be able to call this everywhere in my React app (even inside non-React components), so I won't put it inside a state/context.

  • I want to display all the logs to the user inside a Logs component

👉 I can use useSyncExternalStore inside my Logs component to access the logs and listen to changes.

Sandbox

Use ReactNode instead of JSX.Element | null | undefined | ... to keep your code more compact

I see this mistake a lot

Instead of typing the leftElement and rightElement props like this:

const Panel = ({ leftElement, rightElement }: {
  leftElement:
    | JSX.Element
    | null
    | undefined
    // | ...;
  rightElement:
    | JSX.Element
    | null
    | undefined
    // | ...
}) => {
  //   …
};

You can use ReactNode to keep the code more compact:

const MyComponent = ({ leftElement, rightElement }: { leftElement: ReactNode; rightElement: ReactNode }) => {
  //   …
};

Simplify the typing of components expecting children to props with PropsWithChildren

You don’t have to type the children prop manually.
In fact, you can use PropsWithChildren to simplify the typings.

// 🟠 Ok
const HeaderPage = ({ children,...pageProps }: { children: ReactNode } & PageProps) => {
  //   …
};

// ✅ Better
const HeaderPage = ({ children, ...pageProps } : PropsWithChildren<PageProps>) => {
//   …
};

Access element props efficiently with ComponentProps, ComponentPropsWithoutRef, …

There are cases where you need to figure out a component’s props

For example, let’s say you want a button that will log a console when clicked

You can use ComponentProps to access the props of the button element and then override the click prop.

const ButtonWithLogging = (props: ComponentProps<"button">) => {
  const handleClick: MouseEventHandler<HTMLButtonElement> = (e) => {
    console.log("Button clicked"); //TODO: Better logging
    props.onClick?.(e);
  };
  return <button {...props} onClick={handleClick} />;
};

This trick also works with the custom component.

const MyComponent = (props: { name: string }) => {
  //   …
};

const MyComponentWithLogging = (props: ComponentProps<typeof MyComponent>) => {
  //   …
};

Leverage types like MouseEventHandler, FocusEventHander, and others for concise typing.

Rather than typing the event handlers manually, you can use types like MouseEventHandler to keep the code more concise and readable.

// 🟠 Ok
const MyComponent = ({ onClick, onFocus, onChange }: {
  onClick: (e: MouseEvent<HTMLButtonElement>) => void;
  onFocus: (e: FocusEvent<HTMLButtonElement>) => void;
  onChange: (e: ChangeEvent<HTMLInputElement>) => void;
}) => {
  //   …
};

// ✅ Better
const MyComponent = ({ onClick, onFocus, onChange }: {
  onClick: MouseEventHandler<HTMLButtonElement>;
  onFocus: FocusEventHandler<HTMLButtonElement>;
  onChange: ChangeEventHandler<HTMLInputElement>;
}) => {
  //   …
};

Leverage the Record type for cleaner and more extensible code

I love this helper type.

Let’s say I have a type that represents log levels.

type LogLevel = "info" | "warn" | "error";

We have a corresponding function for each log level that logs the message.

const logFunctions = {
  info: (message: string) => console.info(message),
  warn: (message: string) => console.warn(message),
  error: (message: string) => console.error(message),
};

Instead of typing the logFunctions manually, you can use the Record type.

const logFunctions: Record<LogLevel, (message: string) => void> = {
  info: (message) => console.info(message),
  warn: (message) => console.warn(message),
  error: (message) => console.error(message),
};

Using the Record type makes code more concise and more readable.

Additionally, It helps capture any error if a new log level is added or removed.
For example, If I decided to add a debug log level, Typescript would throw an error.

Use the as const trick to accurately type your hook return value

Let’s say we have a hook useIsHovered to detect whether a div element is hovered.

The hooks return a ref to use with the div element and a boolean indicating whether a div is hovered.

const useIsHovered = () => {
  const ref = useRef<HTMLDivElement>(null);
  const [isHovered, setIsHovered] = useState(false);
  // TODO : Rest of implementation
  return [ref, isHovered]
};

Currently, Typescript will not correctly infer the function return type.

You can either fix this by explicitly typing the return type like this:

const useIsHovered = (): [RefObject<HTMLDivElement>, boolean] => {
  // TODO : Rest of implementation
  return [ref, isHovered]
};

Or you can use the as const trick to accurately type the return values:

const useIsHovered = () => {
  // TODO : Rest of implementation
  return [ref, isHovered] as const;
};
0
Subscribe to my newsletter

Read articles from Tuan Tran Van directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Tuan Tran Van
Tuan Tran Van

I am a developer creating open-source projects and writing about web development, side projects, and productivity.