DEV Community

Cover image for This is how to Optimize React Apps for Performance
Humjerry⚓
Humjerry⚓

Posted on • Edited on

This is how to Optimize React Apps for Performance

1. Introduction

Performance optimization in React is a best practice and more, it is very important because it directly influences the user experience. In a digital age where users are in need of fast, responsive, and seamless applications, the performance of your React app can significantly impact its level success.

React with its declarative and component-Based architecture, helps developers to build dynamic and interactive user interfaces. However, as applications grow in complexity, the need for effective performance optimization becomes paramount. When a React app is well optimized, it not only loads faster but also enhances the overall user experience, giving birth to increased user engagement and satisfaction.

In this comprehensive piece, we'll be looking at key strategies and techniques to optimize the performance of React applications, ensuring that they not only meet user expectations but also excel in terms of speed and responsiveness.

2. Bundle Size Optimization

2.1 Techniques for Reducing Bundle Size
An important aspect of this optimization is reducing the bundle size of a React application, The size of your React application bundle directly correlates with its load time. Smaller bundles result in faster initial page loads and improved performance while Large bundle sizes can lead to slower loading times and increased bandwidth consumption. Let us explore some techniques for reducing the bundle size in React applications.

Tree Shaking
Tree shaking is a technique that involves removing unused code from a bundle. By identifying and eliminating dead code, by doing this, you can effectively reduce the overall bundle size.

// @Desc. utility-library.js
export const calculateTax = (amount, taxRate) => {
  return amount * (taxRate / 100);
};

export const formatCurrency = (amount, currency) => {
  return new Intl.NumberFormat('en-UK', {
    style: 'currency',
    currency,
  }).format(amount);
};

//@Desc. this is an app.js
import { calculateTax } from 'utility-library';

const totalAmount = 500;
const taxRate = 8;

const taxAmount = calculateTax(totalAmount, taxRate);

console.log(`Tax Amount: ${formatCurrency(taxAmount, 'Naira')}`);

Enter fullscreen mode Exit fullscreen mode

The example shows the utility-library contains two functions: usedFunction and unusedFunction. However, in the app.js file, only usedFunction is imported and used. When tree shaking is applied, the unusedFunction will be detected as unused and will be removed from the final bundle. This makes only the necessary code is included in the build, thereby reducing the bundle size. We will take a look at some More in the next section.

2.2 Code Splitting and Dynamic Imports
Here, we will look into Code splitting and Dynamic Imports as techniques for reducing Bundle size in React applications, starting with code splitting.

a. Code Splitting
Code splitting (also known as Chunking)allows you to break down your application into smaller and more manageable Pieces, loading only the necessary parts when required. This is especially beneficial to large applications with multiple routes or features.

//@Desc. this is Dashboard.js
import React, { lazy, Suspense, useState } from 'react';

const WeatherWidget = lazy(() => import('./widgets/WeatherWidget'));
const CalendarWidget = lazy(() => import('./widgets/CalendarWidget'));
const NewsWidget = lazy(() => import('./widgets/NewsWidget'));

const Dashboard = () => {
  const [selectedWidget, setSelectedWidget] = useState(null);

  const loadWidget = async (widgetName) => {
    const module = await import(`./widgets/${widgetName}`);
    setSelectedWidget(module.default);
  };

  return (
    <div>
      <h1>Dashboard</h1>
      <button onClick={() => loadWidget('WeatherWidget')}>Load Weather Widget</button>
      <button onClick={() => loadWidget('CalendarWidget')}>Load Calendar Widget</button>
      <button onClick={() => loadWidget('NewsWidget')}>Load News Widget</button>

      <Suspense fallback={<div>Loading...</div>}>
        {selectedWidget && <selectedWidget />}
      </Suspense>
    </div>
  );
};

export default Dashboard;

Enter fullscreen mode Exit fullscreen mode

considering the dashboard application above where different widgets provide various functionalities. the Dashboard component allows users to dynamically load different widgets based on their preferences. Each widget is implemented in a separate module, then the code splitting is applied using the lazy function.

b. Lazy Loading
Dynamic imports facilitate lazy loading, which means resources are loaded only when they are needed. This approach helps reduce the initial payload size, because users don't have to wait for the entire application to load before they can interact with it. Instead of waiting, they can access the essential parts of the application right away, and the rest of the content is loaded in the background. It can be achieved in React application by using the React.lazy function along with dynamic import() statements.

3. Data Fetching and State Management

3.1 Efficient data fetching strategies
Here, we will be looking at React Query as a strategy for efficient data fetching.
a. React Query
React Query is a popular library that handles data fetching, caching, and updating in a clear and powerful way. It automatically refetches data when needed and provides built-in error handling and caching mechanisms.
Considering a DataFetchingComponent that uses useQuery hook to fetch data. The QueryClientProvider wraps the App component, providing a global context for managing queries. The data fetched is cached automatically by React Query, and the loading state is managed efficiently.

//@Desc. we have to Install React Query: npm install react-query

//@Desc. this is App.js
import React from 'react';
import { useQuery, QueryClient, QueryClientProvider } from 'react-query';

//@Desc. Create a new instance of QueryClient
const queryClient = new QueryClient();

const fetchData = async () => {
  //@Desc. Simulate fetching data from an API
  const response = await fetch('https://api.example.com/data');
  const result = await response.json();
  return result;
};

const DataFetchingComponent = () => {
  //@Desc. Use the useQuery hook to fetch and manage data
  const { data, isLoading } = useQuery('data', fetchData);

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

  return (
    <div>
      <h1>Data Fetching with React Query</h1>
      {/* Render your component with the fetched data */}
      <p>Data: {data}</p>
    </div>
  );
};

const App = () => {
  return (
    //@Desc.  Wrap your application with QueryClientProvider
    <QueryClientProvider client={queryClient}>
      <DataFetchingComponent />
    </QueryClientProvider>
  );
};

export default App;

Enter fullscreen mode Exit fullscreen mode

3.2 Caching and optimizing state management
Optimizing state management is important for preventing unnecessary re-renders and ensuring a responsive user interface. Proper state management does a lot and can significantly impact performance in React.

Using React's useMemo for Memoization
React's useMemo hook is useful for memoizing values and preventing unnecessary calculations or renders. Let's consider a scenario where a derived value is computed based on other state values:

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

const StateManagementComponent = () => {
  const [value1, setValue1] = useState(10);
  const [value2, setValue2] = useState(20);

  //@Desc. Memoized calculation
  const derivedValue = useMemo(() => {
    console.log('Recalculating derived value');
    return value1 + value2;
  }, [value1, value2]);

  return (
    <div>
      <p>Value 1: {value1}</p>
      <p>Value 2: {value2}</p>
      <p>Derived Value: {derivedValue}</p>
    </div>
  );
};

export default StateManagementComponent;

Enter fullscreen mode Exit fullscreen mode

Looking at the illustration above, the derivedValue is calculated using useMemo, making sure the calculation is performed only when value1 or value2 changes, preventing unnecessary recalculations.

4. Core Web Vitals and React

4.1 Overview of Core Web Vitals metrics
Core Web Vitals are a set of three key metrics introduced by Google to help website owners understand how users see and perceive the performance, responsiveness, and visual balance of their web pages. Now let us look at the Core Web Vitals metrics which include:
a. Largest Contentful Paint (LCP): The LCP metric measures the loading performance of a web page by determining when the largest content element within the viewport has finished loading. React, LCP in React can be optimized by code splitting, compressing assets, preloading critical resources.

b. First Input Delay (FID): This metric measures the time it takes for a user's first interaction with your web page, such as clicking a button or tapping a link. To optimize FID in a React application, minimize JavaScript Execution, Use event Delegation and Prioritize critical JavaScript.

c. Cumulative Layout Shift (CLS): This metric measures visual stability by evaluating how often users experience unexpected layout shifts. To optimize CLS in a React application, provide size attribute for images using width and height, Avoid dynamically injecting content above the fold, Use CSS properties for sizing.

4.2 How React applications can meet LCP, FID, and CLS criteria
Now facing the reality question, how React apps can meet the LCP,FID and FID criteria. Let us consider the following:
a. Optimizing for LCP by efficiently load critical assets, employ lazy loading for images and components that are not immediately visible and Compress and serve images in modern formats to reduce their size.
b. Improving FID by minimizing JavaScript execution time, make use of asynchronous techniques to ensure non-blocking execution and Streamline event handlers to be concise and responsive.
c. Enhancing CLS by ensuring that content added to the DOM dynamically does not disrupt the existing layout, reserve space for images and videos with fixed dimensions to prevent sudden layout shifts and Implement animations thoughtfully to prevent unintended layout shifts.

4.3 Practical tips for optimizing images and fonts
Let's see some code illustrations on how we can optimize fonts and images.
Image optimization

//@Desc. using responsive image with the 'srcset' attribute
<img
  src="large-image.jpg"
  srcSet="medium-image.jpg 800w, small-image.jpg 400w"
  sizes="(max-width: 600px) 400px, (max-width: 800px) 800px, 1200px"
  alt="Responsive Image"
/>

Enter fullscreen mode Exit fullscreen mode

Font optimization

/*@Desc.  using font-display: swap in CSS */
@font-face {
  font-family: 'YourFont';
  src: url('your-font.woff2') format('woff2');
  font-display: swap;
}

Enter fullscreen mode Exit fullscreen mode

Haven seen this, let us continue to the next chapter of this article

5. Lazy Loading and React Suspense

Lazy loading as a technique that defers the loading of certain parts of your React application until they are actually needed, can significantly improve the initial page load time and user experience. React provides a built-in feature for lazy loading components using the React.lazy function.

5.1 Lazy Loading React Components
Consider a scenario where you have a large component that is not critical for the initial page load. You can lazily load it when it's actually rendered in the application, look at this:

//@Desc. LargeComponent.js
const LargeComponent = () => {
  //@Desc. Your large component logic
};

export default LargeComponent;

Enter fullscreen mode Exit fullscreen mode
//@Desc. App.js
import React, { lazy, Suspense } from 'react';

const LargeComponent = lazy(() => import('./LargeComponent'));

const App = () => {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <LargeComponent />
      </Suspense>
    </div>
  );
};

export default App;

Enter fullscreen mode Exit fullscreen mode

With the above illustration, you can se the LargeComponent is loaded lazily when it's actually rendered in the App component. The Suspense component is used to provide a fallback user interface (UI) while the module is being loaded.

5.2 Utilizing React Suspense for concurrent rendering
React Suspense is a powerful feature that allows components to "suspend" rendering while waiting for some asynchronous operation to complete, such as fetching data or loading a module. This can enhance the user experience by maintaining a smooth transition between loading states and avoiding UI flickering.

Let's see how we can use React Suspense for Data Fetching

//@Desc.  DataFetchingComponent.js
import React, { Suspense } from 'react';

const fetchData = () => {
  //@Desc. Simulate fetching data from an API
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('Data loaded successfully');
    }, 2000);
  });
};

const DataComponent = () => {
  const data = fetchData();

  return <p>{data}</p>;
};

const DataFetchingComponent = () => {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <DataComponent />
      </Suspense>
    </div>
  );
};

export default DataFetchingComponent;

Enter fullscreen mode Exit fullscreen mode

Examining this example, the DataFetchingComponent uses Suspense to handle the loading state while the DataComponent is fetching data asynchronously. The fallback UI is displayed until the asynchronous operation is complete.

5.3 Improving initial page load times with lazy loading
Improving initial page load times is an important aspect of optimizing the user experience in React applications. Lazy loading is identified as a powerful technique to achieve this by deferring the loading of non-essential components until they are actually needed. Let's see how lazy loading contributes to faster initial page load times.

Lazy Loading Components and Assets
In a typical React application, you may have components that are not immediately visible or required for the initial view. By lazily loading these components, you can significantly reduce the initial bundle size and, consequently, the time it takes to load the page.

Consider the following example where a large feature module is lazily loaded:

//@Desc. LargeFeatureModule.js
const LargeFeatureModule = () => {
  // Your large feature module logic
};

export default LargeFeatureModule;

Enter fullscreen mode Exit fullscreen mode
//@Desc. App.js
import React, { lazy, Suspense } from 'react';

const LargeFeatureModule = lazy(() => import('./LargeFeatureModule'));

const App = () => {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        {/* Lazily load the large feature module */}
        <LargeFeatureModule />
      </Suspense>
    </div>
  );
};

export default App;

Enter fullscreen mode Exit fullscreen mode

In this example, the UtilityFunctions are dynamically imported and loaded when the ComponentUsingUtility is rendered. This helps reduce the initial bundle size and improves the time it takes to load the page.

6. Performance Monitoring Tools

6.1 Introduction to tools like Lighthouse and WebPageTest
It is important Monitor and analyze the performance of your React application, for identifying areas of improvement and ensuring a smooth user experience. Lighthouse and WebPageTest are two powerful tools for performance evaluation. Now let's see them one after another.

Lighthouse
Lighthouse is an open-source, automated tool for improving the quality of web pages. It has audits for performance, accessibility, progressive web apps, SEO, and more. It can be run against any web page, public or requiring authentication, directly from the Chrome DevTools, from the command line, or as a Node module.

This is how to use Lighthouse in Chrome DevTools:

  1. Open Chrome DevTools (Ctrl+Shift+I or Cmd+Opt+I on Mac).
  2. Go to the "Audits" tab.
  3. Click on "Perform an audit" and select the desired audit categories.
  4. Click "Run audits."
  5. Lighthouse provides a detailed report with scores and recommendations for improvement.

WebPageTest
WebPageTest is an online tool for running web performance tests on your site. It allows user to simulate the loading of a webpage under different conditions, such as various network speeds and device types. It provides a waterfall chart, filmstrip view, and detailed metrics to help you understand how your webpage loads.

This is how to use WebPageTest:

  1. Visit the WebPageTest website, you can check out catchpoint
  2. Enter the URL of your webpage.
  3. Choose test configurations such as location, browser, and connection speed.
  4. Click "Start Test." WebPageTest generates a comprehensive report with details about the loading process, including time to first byte (TTFB), page load time, and visual progress.

6.2 Setting benchmarks and analyzing performance results

Setting Benchmarks with Lighthouse

  1. Run Lighthouse audits for your React application.
  2. Evaluate the scores and recommendations provided by Lighthouse.
  3. Set benchmarks based on industry standards or your specific performance goals.
  4. Identify areas where your application falls short and needs improvement.

Analyzing Performance Results with WebPageTest

  1. Run WebPageTest with various configurations to simulate different user scenarios.
  2. Examine the waterfall chart to identify bottlenecks and loading patterns.
  3. Review filmstrip views to visualize how the page renders over time.
  4. Analyze metrics such as time to first byte (TTFB), start render time, and fully loaded time.
  5. Compare results across different test configurations to understand performance variations.

7. Third-Party Libraries and Performance

7.1 Impact of third-party libraries on React app performance
Integrating third-party libraries into our React application can enhance functionality, also save development time. However, it's important to be mindful of the potential impact on performance. Because poorly optimized or weighty libraries can adversely affect our application's speed and user experience.

Evaluating Third-Party Libraries
Bundle Size:

  1. Check the size of the library's distribution files.
  2. Consider using tools like Bundlephobia or Webpack Bundle Analyzer to analyze the impact on your bundle size.

Network Requests:

  1. Evaluate the number of additional network requests the library introduces.
  2. Minimize external dependencies that increase the overall request count.

Execution Time:

  1. Assess the runtime performance of the library.
  2. Look for potential bottlenecks or performance issues in the library's code.

7.2 Balancing functionality with performance considerations
Code Splitting for Third-Party Libraries
Implementing code splitting is an effective strategy to load third-party libraries only when they are required, reducing the initial page load time. Use dynamic imports to load the library lazily:

//@Desc. Dynamically import a third-party library
const loadThirdPartyLibrary = () => import('third-party-library');

//@Desc. Component using the library
const MyComponent = () => {
  const ThirdPartyLibrary = React.lazy(loadThirdPartyLibrary);

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <ThirdPartyLibrary />
    </Suspense>
  );
};

Enter fullscreen mode Exit fullscreen mode

Tree Shaking for Bundle Size Optimization
Make sure your bundler, such as Webpack, supports tree shaking. Tree shaking eliminates dead code (unused exports) from your final bundle, reducing its size. Tree shaking can be more effective If the third-party library supports ES modules.

Monitor and Update Dependencies
Update your third-party libraries to benefit from performance improvements and bug fixes. Check for updates regularly and use tools like Dependabot to automate dependency updates.

Profile and Optimize
Profile your application using performance monitoring tools like Lighthouse and WebPageTest. Identify the impact of third-party libraries on your application's performance and optimize accordingly. Prioritize critical functionality and evaluate the necessity of each library.

8. Real-world Examples and Case Studies

8.1 Demonstrating performance improvements in actual React projects

Case Study 1: Bundle Size Optimization

Problem:
A React e-commerce application was facing sluggish initial page load times due to a large bundle size.

Solution:
Identifying and Code Splitting:

  • Identified the product listing page as non-critical for initial rendering.
  • Implemented code splitting using React.lazy for the product listing component.
//@Desc. Before
import ProductListing from './components/ProductListing';

//@Desc. After
const ProductListing = React.lazy(() => import('./components/ProductListing'));

Enter fullscreen mode Exit fullscreen mode

1. Tree Shaking:
Reviewed dependencies and ensured effective tree shaking.
Updated the Webpack configuration to support tree shaking.
Result:
Reduced the initial bundle size by 25%, resulting in a noticeable improvement in page load times, especially for users on slower networks.

Case Study 2: Data Fetching Optimization

Problem:
A React dashboard with dynamic data was experiencing delays in rendering and interactivity.

Solution:

1. Lazy Loading with React Suspense:

  • Implemented lazy loading for data-heavy components using React.lazy.
  • Utilized React Suspense to gracefully handle loading states.
const LazyDataComponent = React.lazy(() => import('./components/LazyDataComponent'));

const Dashboard = () => {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LazyDataComponent />
    </Suspense>
  );
};

Enter fullscreen mode Exit fullscreen mode

1. React Query for Data Management:

  • Integrated React Query for efficient data fetching and caching.
const fetchData = async () => {
  const response = await fetch('https://api.example.com/dashboard-data');
  const result = await response.json();
  return result;
};

const { data, isLoading } = useQuery('dashboardData', fetchData);

Enter fullscreen mode Exit fullscreen mode

Result:
Improved the dashboard's overall perceived performance by 30%, providing a smoother and more responsive user experience, especially when interacting with real-time data.

8.2 Practical takeaways for developers to implement in their projects

1. Prioritize Critical Components:

  • Identify components critical for the initial view and load them upfront.
  • Use code splitting for non-essential components to reduce initial bundle size.

2. Optimize Data Fetching:

  • Employ React Suspense for lazy loading and a seamless loading experience.
  • Consider adopting specialized libraries like React Query for efficient data management.

3. Regularly Audit and Update Dependencies:

  • Keep third-party libraries up to date to benefit from performance enhancements.
  • Conduct routine audits using tools like Lighthouse and WebPageTest.

4. Balance Functionality with Performance:

  • Assess the necessity of third-party libraries and features.
  • Strive for a balanced approach, optimizing functionality without compromising performance.

Conclusion

making React apps faster is like putting together a puzzle. You need to make smart choices, follow good practices, and use the right tools. Throughout this guide, we looked at different ways to speed up React apps, like making the initial load quicker and fetching data more efficiently. The real-world examples showed how these tricks can solve real problems. I hope this guide helps you make your React apps speedy and smooth.

Top comments (0)