DEV Community

Cover image for Boosting React App Performance: Techniques and Best Practices
Gervais Yao Amoah
Gervais Yao Amoah

Posted on

Boosting React App Performance: Techniques and Best Practices

Performance is a critical factor in creating any web applications that users love. Slow-loading pages and unresponsive user interfaces can drive visitors away and affect your app's success. There are many techniques and practices available to help you optimize your application and make it perform better. In the following sections, we'll dive into some of them that will help you fine-tune your React app's speed, responsiveness, and overall user experience.

Code Splitting

In modern web development, optimizing your application's performance often means making it load faster and reducing its initial bundle size. Code splitting is a technique that addresses both of these concerns. It involves dividing your JavaScript codebase into smaller bundles that are loaded on-demand, rather than all at once during the initial page load. This means that only the necessary code is fetched when a user navigates to a specific part of your application.
In React, you can achieve code splitting using tools like React's lazy and Suspense features.

const SomeComponent = React.lazy(() => import('./SomeComponent'));
Enter fullscreen mode Exit fullscreen mode

Real World Usage

Imagine building a dashboard application with a login screen that requires user authentication. Traditionally, when a user visits your application, the entire dashboard, along with various authenticated components, would load during the initial page load. While this approach works, it often results in slower loading times and can potentially pose security risks.
Now, consider the power of code splitting. With this technique, you can load only the login screen when a user first visits your application. This means that your application's core code, responsible for rendering the dashboard and authenticated components, remains untouched until the user successfully logs in.
The benefits are twofold. Firstly, your users experience faster loading times during their initial visit, as they only need to fetch the code essential for the login screen. Secondly, from a security standpoint, you've effectively minimized the risk of loading and rendering any authenticated screens until the user has successfully authenticated themselves.
In essence, code splitting in this scenario not only enhances performance but also aligns perfectly with a security-first approach, making your application faster and safer for your users. And this is just one example of how code splitting can be a game-changer in optimizing React app performance. Let's explore more performance-boosting techniques next.

useMemo for Expensive Calculations

In React, the useMemo hook is a powerful tool to optimize the performance of your application by memoizing expensive calculations. But what does that even mean?
Imagine you have a component that performs complex calculations or data transformations every time it renders. These calculations might not change between renders unless some dependencies change. Without memoization, your component would recompute these expensive operations on each render, even if nothing relevant has changed. This can lead to unnecessary performance bottlenecks.
This is where useMemo comes to the rescue. It allows you to store the result of a calculation and only recompute it when the inputs (dependencies) to that calculation change. In essence, it's a way to remember values between renders.

import React, { useMemo } from 'react';

function ExpensiveComponent({ data }) {
  // Without useMemo: this expensive calculation runs on every render
  const resultWithoutMemo = calculateExpensiveResult(data);

  // With useMemo: the calculation only runs when 'data' changes
  const resultWithMemo = useMemo(() => calculateExpensiveResult(data), [data]);

  return (
    <div>
    <h2>Expensive Component</h2>
    <p>Result without useMemo: {resultWithoutMemo}</p>
    <p>Result with useMemo: {resultWithMemo}</p>
    </div>
  );
}

function calculateExpensiveResult(data) {
  // Simulate a time-consuming computation
}
Enter fullscreen mode Exit fullscreen mode

Incorporating useMemo into your React applications can significantly boost performance by preventing unnecessary recalculations of expensive functions. By using it, you can ensure that your React app runs smoothly and efficiently.

Optimizing Event Handling with useCallback

The useCallback hook is used for optimizing functions in your React components. It's particularly useful when dealing with functions that are passed as props to child components or used as dependencies in other hooks or effects. Let’s see how it works and when to use it.
Imagine you have a React component that renders a large list of items, and each item has an onClick handler. When one of these items is clicked, you want to perform some action. Now, the catch is that creating a new callback function for each item click can lead to performance issues, especially in larger applications. This is where the useCallback hook comes to the rescue.
Here's how it works: When you wrap a function with useCallback, React will memoize that function. This means that React will only create a new instance of the function if its dependencies change. If the dependencies remain the same, React will reuse the previously memoized function, which can help optimize rendering performance.

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

function ParentComponent() {
  const [items, setItems] = useState([
    { id: 1, text: 'Item 1' },
    { id: 2, text: 'Item 2' },
    { id: 3, text: 'Item 3' },
          //… a lot more items
  ]);

  const handleItemClick = useCallback((itemId) => {
    // Logic to handle item click
    console.log(`Item with ID ${itemId} clicked`);
  }, []);

  return (
    <div>
    <h2>List of Items</h2>
    <ul>
        {items.map((item) => (
        <li key={item.id} onClick={() => handleItemClick(item.id)}>
            {item.text}
        </li>
        ))}
    </ul>
    </div>
  );
}

export default ParentComponent;
Enter fullscreen mode Exit fullscreen mode

In this example, if we didn’t use useCallback, the handleItemClick function would be recreated for each item in the list, and that can lead to performance issues when dealing with a large number of items. By using useCallback to memoize the handleItemClick function, we ensure that it's not recreated for each item in the list. This will improve the performance of your component.

useMemo vs. useCallback

It seems that some newcomers to React confuse useCallback and useMemo. Although both use memoization to prevent unnecessary work, they serve distinct purposes. So, let's explore how these two hooks differ from each other.

1. Purpose:

  • useCallback: It's primarily used to memoize functions to prevent unnecessary re-creation of functions, especially when those functions are used as dependencies in child components or hooks like useEffect.
  • useMemo: It's used to memoize the result of expensive calculations, such as computations or data transformations, to prevent those calculations from being re-executed on every render.

2. Usage:

  • useCallback: It takes two arguments - the callback function and an array of dependencies. The function is re-created only when one or more of the dependencies change.
  • useMemo: It also takes two arguments - a function and an array of dependencies. The function is re-executed when one or more of the dependencies change, and its return value is memoized.

3. Return Value:

  • useCallback: It returns a memoized version of the callback function.
  • useMemo: It returns the memoized result of the function.

4. When to Use:

  • useCallback: Use it when you want to prevent function re-creation for event handlers or functions passed as props.
  • useMemo: Use it when you want to memoize the result of calculations or data transformations.

In summary, both useCallback and useMemo are powerful hooks for optimizing React applications, but they serve different purposes. The choice between them depends on your specific use case and optimization needs.

Leveraging Web Workers

React applications typically run all JavaScript code on a single main thread. While this works well for most tasks, it can lead to performance bottlenecks when handling heavy computations or tasks that require significant processing power.
Web workers provide a solution to this problem by allowing you to run JavaScript code in the background, off the main thread. This can significantly improve the responsiveness and performance of your React app.
For a more in-depth exploration of web workers, you can dive into this comprehensive guide. Here, we'll focus on how to effectively harness web workers within React applications to supercharge their performance.
Let's say you have a React application that performs a computationally intensive task, like generating prime numbers, which can slow down the main thread. We'll offload this task to a web worker to keep the UI responsive.

Step 1: Create a Web Worker File

First, we create a JavaScript file named primeWorker.js. This file will contain the web worker's code. To make things simpler, we will use Comlink and Workerize

// primeWorker.js
import { expose } from 'comlink';

function findPrimesInRange(start, end) {
  const primes = [];
  for (let num = start; num <= end; num++) {
    if (isPrime(num)) {
    primes.push(num);
    }
  }
  return primes;
}

function isPrime(num) {
  if (num <= 1) return false;
  if (num <= 3) return true;
  if (num % 2 === 0 || num % 3 === 0) return false;
  let i = 5;
  while (i * i <= num) {
    if (num % i === 0 || num % (i + 2) === 0) return false;
    i += 6;
  }
  return true;
}

expose(findPrimesInRange);
Enter fullscreen mode Exit fullscreen mode

Step 2: Use the Web Worker in the React Component

Now, we use Workerize in the React component to create a web worker from the primeWorker.js file.

import React, { useState } from 'react';
import workerize from 'workerize-loader!./primeWorker';

function PrimeGenerator() {
  const [primes, setPrimes] = useState([]);
  const [loading, setLoading] = useState(false);

  const handleGeneratePrimes = async () => {
    setLoading(true);

    const worker = workerize(); // Create a new web worker

    try {
    const findPrimesInRange = worker(); // Create a function proxy for the worker
    const result = await findPrimesInRange(2, 1000); // Use the worker function

    setPrimes(result);
    } catch (error) {
    console.error('Web worker error:', error);
    } finally {
    setLoading(false);
    worker.terminate(); // Terminate the worker when done
    }
  };

  return (
    <div>
    <button onClick={handleGeneratePrimes} disabled={loading}>
        Generate Primes
    </button>
    {loading ? <p>Loading...</p> : null}
    <ul>
        {primes.map((prime) => (
        <li key={prime}>{prime}</li>
        ))}
    </ul>
    </div>
  );
}

export default PrimeGenerator;
Enter fullscreen mode Exit fullscreen mode

This approach ensures that the heavy computation is done in the background, keeping the UI responsive. When the computation is finished, the web worker is terminated to release its resources.
By using web workers in this way, you can optimize performance for CPU-intensive tasks in your React app while maintaining a smooth user experience.

Optimizing React Rendering with React.memo (we use memo now)

React.memo is a higher-order component that can significantly optimize your React application by preventing unnecessary re-renders. It's a simple yet powerful tool for boosting performance in your components.
Let’s imagine we have a dynamic list of items in our React component, and some of these items can change over time. We want to make sure that when an item changes, only that specific item gets re-rendered, not the entire list. This is where React.memo shines.
Let's see an example with a list of user profiles:

import React from 'react';

// UserProfile component represents a single user profile
function UserProfile({ user }) {
  return (
    <div>
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
    </div>
  );
}

function UserList({ users }) {
  return (
    <div>
    {users.map((user) => (
        <UserProfile key={user.id} user={user} />
    ))}
    </div>
  );
}

// Sample user data
const usersData = [
  { id: 1, name: 'Alice', email: 'alice@example.com' },
  { id: 2, name: 'Bob', email: 'bob@example.com' },
  // ... More users here
];

function App() {
  const [users, setUsers] = React.useState(usersData);

  // Simulate a change in the user data
  const updateUser = () => {
    const updatedUsers = [...users];
    updatedUsers[0].name = 'Alicia'; // Changing Alice's name
    setUsers(updatedUsers);
  };

  return (
    <div>
      <h1>User Profiles</h1>
      <UserList users={users} />
      <button onClick={updateUser}>Update User</button>
    </div>
  );
}

export default App;

Enter fullscreen mode Exit fullscreen mode

When you click the "Update User" button, it changes Alice's name from "Alice" to "Alicia."
Without any optimizations, clicking the button would re-render the entire UserList component, even though only one user's data changed.
Now, let's optimize this scenario using React.memo. We'll wrap the UserProfile component with React.memo to ensure that only the changed user profile gets re-rendered, not the entire list:

const UserProfile = React.memo(({ user }) => {
  return (
    <div>
    <h2>{user.name}</h2>
    <p>Email: {user.email}</p>
    </div>
  );
}, (prevProps, nextProps) => {
  // Custom comparator
  return (
    prevProps.user.name === nextProps.user.name &&
    prevProps.user.username === nextProps.user.username
  );
});
Enter fullscreen mode Exit fullscreen mode

In this updated UserProfile component, we've wrapped it with React.memo, which will memoize the component and re-render it only when its props change. We've also provided a custom comparator function as the second argument to React.memo. This function compares the previous and next props and returns true if the email address of the user has changed, indicating that a re-render is necessary.
You might be wondering why do we need to add a custom comparator function. Well, when you use React.memo (you should use memo now) without providing a custom comparator, by default React will compare each prop with Object.is.

Object.is function in action

Incorporating React.memo with a custom comparator allows you to fine-tune component re-rendering based on specific prop changes. This optimization technique is particularly useful when dealing with complex props or scenarios where shallow comparisons may not be sufficient. However, if you don’t have complex components or scenarios where you want to customize how props are compared for memoization, you can avoid providing a comparator function.

Additional Considerations for Optimal React Performance

As we wrap up our exploration of these React performance optimization techniques, we've covered several strategies with practical examples. Now, let's delve into some more best practices and insights that can further enhance the performance of your React applications. While we won't be diving into code implementations for these, they provide valuable insights and considerations to keep in mind as you work on optimizing your projects.

Windowing (React-Virtual)

Windowing, in the context of web development, refers to a technique used to optimize the rendering of large lists or grids of items in a web application. Instead of rendering all items at once, windowing only renders a subset of visible items, improving performance and reducing memory usage.
To achieve this in your React app, you can leverage the npm package react-virtual

State Colocation

In React applications, especially when utilizing global state management like the Context API, there's a common tendency to throw every possible piece of states into the global store. This might seem convenient at first, but it can lead to suboptimal performance. Picture this scenario: you have a handful of components that need certain pieces of state, and they're situated close to each other in your component tree. However, because you've centralized all your state in a global provider, any change to that state triggers unnecessary re-renders for all components using the context provider, even if they don't require that particular piece of state.
State colocation offers a solution by encouraging a more deliberate approach to state management. Instead of centralizing all states, it promotes placing states near the components that need them. By doing so, unnecessary re-renders become a thing of the past, and your application's performance receives a significant boost.

Using Multiple Contexts

In some scenarios, your components might need access to the same state, but they're not closely located in your component tree. State colocation, as we discussed earlier, might not be the ideal solution here. However, the last thing you want is to introduce unnecessary re-renders by providing all components with a single, massive context.
This is where the technique of splitting a single context into multiple contexts comes into play. By segmenting your state into smaller, more focused contexts, you strike a balance between sharing state across your application and ensuring that each component receives only the data it requires.
This approach offers the best of both worlds: efficient state sharing and optimized performance, all achieved by strategically dividing your state into smaller, contextually relevant pieces.

Time Slicing and Concurrent Mode

React Concurrent Mode introduces the concept of asynchronous rendering, making your app more responsive. Time slicing is a key feature of Concurrent Mode. It allows React to break down complex rendering tasks into smaller, manageable chunks.
In traditional React, complex renders can block your app's main thread, like starting to write in a text input where each change calls a complex filter function of a really large list of data, leading to unresponsiveness. With time slicing, React can handle large updates in pieces, keeping your app responsive even during intensive tasks.

Leveraging Suspense

React Suspense is a groundbreaking feature in React that enhances the way we handle asynchronous operations in our applications. It complements time slicing by simplifying asynchronous operations. While it's closely associated with React Concurrent Mode, it can also be used in non-Concurrent Mode applications.
In traditional React, managing asynchronous operations like data fetching and code splitting could be complex. Developers often used libraries like Redux-Saga or Redux-Thunk to handle async data. With React Suspense, handling asynchronous tasks becomes more straightforward. You can use Suspense components to specify loading states and error boundaries for different parts of your application.
React Suspense provides an elegant way to handle loading states. You can wrap components that fetch data in a Suspense boundary, allowing you to show loading indicators while data is being fetched. This simplifies your code by eliminating the need for conditional rendering based on loading states.
Suspense also plays a crucial role in code splitting. You can use it to specify loading states for different code-split components. This means you can easily defer loading parts of your application until they are needed, improving initial load times.

Conclusion: Elevating React App Performance

In this journey through performance optimization in React applications, we've explored various techniques – from code splitting to web workers, state colocation to multiple contexts, and React Suspense. Each technique offers unique strengths.
However, remember this crucial rule: always measure. Before implementing any optimizations, profile your app's current performance. Identify bottlenecks and areas for improvement. Then, choose the techniques that promise the most significant impact.
After applying optimizations, measure again using tools like Chrome DevTools and Lighthouse. Ensure your efforts deliver the desired improvements, making your app faster and more efficient.
The React ecosystem evolves continuously, offering new ways to enhance performance. Stay updated and keep refining your apps for a smoother, responsive user experience. Whether you're working on a personal project or a large-scale app, these optimizations can make your React applications shine.
It's your turn now – go optimize your React apps and contribute to a faster, more enjoyable web for all!

PS: Want More in-Depth Insights?
If you'd like a deeper dive into any of the performance optimization techniques covered in this article, or you know of any other techniques that weren't covered here, share them in the comments so we can all learn and improve!

Top comments (0)