5 common ways to optimize performance of React apps

Mayank C
Tech Tonic

--

Introduction

React, with its component-based architecture and virtual DOM, has become a leading choice for building dynamic and interactive user interfaces. However, as applications grow in complexity and data size, ensuring optimal performance becomes crucial for delivering a smooth and engaging user experience. Performance issues like slow loading times, laggy interactions, or unresponsive UI can significantly impact user engagement and overall satisfaction. In today’s fast-paced web environment, users expect quick responses and seamless interactions. Delays caused by performance bottlenecks can lead to user frustration, abandonment, and ultimately, lost conversions or decreased engagement. Optimizing your React application’s performance not only enhances user experience but also benefits your bottom line and brand reputation.

While numerous techniques contribute to a performant React application, here are five crucial areas to consider:

  • Minimizing Re-renders: Avoid unnecessary updates to components by utilizing techniques like React.memo, useMemo, and strategic state management.
  • Lazy Loading and Code Splitting: Defer loading of components and modules until they are needed, reducing initial bundle size and improving perceived performance.
  • Virtualization: For large lists and grids, implement virtualization techniques to render only visible items, ensuring smooth scrolling and efficient DOM manipulation.
  • Debounce and Throttle Events: Optimize how functions respond to rapid user interactions like typing or scrolling, preventing excessive function calls and improving responsiveness.
  • Image Optimization: Choose optimal formats, compress effectively, resize appropriately, and consider lazy loading to minimize image file sizes and reduce initial load times.

In this article, we’ll cover the above five techniques in detail. Let’s get started.

Technique 1 — Minimizing Re-renders

Within React applications, the virtual DOM plays a crucial role in determining performance. Whenever data or configurations change, React triggers a reconciliation process through the virtual DOM to update the actual DOM efficiently. However, excessive re-renders can significantly impact responsiveness and user experience. Minimizing unnecessary re-renders is therefore paramount for optimal performance.

Factors triggering re-renders

  • Component props: If a component receives new props, it will re-render unless React.memo or shallow props comparison prevent it.
  • Component state: State changes directly trigger re-renders of the component and its children.
  • Context changes: Any modifications to the React context initiate re-renders in components utilizing useContext.
  • Expensive calculations or functions: Performing complex calculations within the render function can lead to unnecessary re-renders if their dependencies aren’t well-handled.

Optimization techniques

  1. React.memo: This higher-order component memoizes functional components, skipping re-renders if their props haven't changed. Ideal for pure components with no side effects.
  2. useMemo and useCallback: These hooks allow memoizing expensive calculations or callback functions, ensuring they only execute when their dependencies change.
  3. State optimization:
  • Avoid directly mutating state; use functional updates or immutable libraries like immer to ensure predictable re-renders.
  • Consider useState alternatives like useReducer for complex state management scenarios.

4. Structural sharing: Avoid creating new objects in the render function. Utilize techniques like spread syntax and object pooling to reuse existing structures if possible.

5. Conditional rendering: Wrap expensive rendering logic in if statements or ternary operators to conditionally render based on actual needs.

6. Lazy initialization: Delay component initialization or data fetching until absolutely necessary, using strategies like React.lazy or dynamic imports.

Code Samples

Using React.memo for a pure component:

import React, { memo } from 'react';

const ProductCard = memo(({ product }) => {
// Render product details without side effects
return (
<div>
<h2>{product.name}</h2>
<p>{product.description}</p>
</div>
);
});

function App() {
const [products, setProducts] = useState([]);

// ... fetch or update products

return (
<div>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}

Using useMemo to avoid unnecessary calculations

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

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

const filteredItems = useMemo(() => {
// Expensive filtering logic based on count
return /* filtered items */;
}, [count]);

return (
<div>
<button onClick={() => setCount(count + 1)}>Increment ({count})</button>
<ul>
{filteredItems.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}

Technique 2 — Lazy Loading and Code Splitting

In React applications, initial load time is crucial for user experience. However, including all functionalities and components in the initial bundle can create large file sizes, leading to slow loading times, especially on slower internet connections.

Lazy Loading and Code Splitting

  • Lazy Loading: Defers loading of components or modules until they are actually needed by the user, reducing the initial bundle size.
  • Code Splitting: Divides your application code into smaller bundles that can be loaded independently, allowing browsers to download only the required parts.

Benefits

  • Faster initial load: Users download less content initially, improving perceived performance.
  • Improved resource management: Unused components don’t consume memory until needed.
  • Modular architecture: Promotes separation of concerns and maintainability.

Techniques

  1. Dynamic Imports: Use the import() function to load modules on demand at runtime.
const OtherComponent = React.lazy(() => import('./OtherComponent'));

function App() {
// ...
return (
<div>
<button onClick={() => setLoadOtherComponent(true)}>Load Other Component</button>
{loadOtherComponent && <OtherComponent />}
</div>
);
}

2. React.lazy and Suspense: Utilize built-in functions for lazy loading components.

const OtherComponent = React.lazy(() => import('./OtherComponent'));

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

3. Third-party libraries: Consider libraries like react-router-dom for route-based code splitting or loadable-components for advanced configuration.

4. Using react-router-dom for route-based splitting

import { BrowserRouter, Routes, Route } from 'react-router-dom';

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route
path="/other"
element={
<React.Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</React.Suspense>
}
/>
</Routes>
</BrowserRouter>
);
}

Technique 3 — Virtualization

Virtualization is a performance optimization technique for efficiently rendering large lists and grids in React applications. Instead of rendering every item in the data set at once, virtualization only renders the items currently visible in the user’s viewport. This significantly reduces the initial DOM size and the amount of computation required, leading to smoother scrolling and faster rendering, especially for massive datasets.

React builds the actual DOM based on a virtual representation maintained in memory. When dealing with extensive lists, rendering all items simultaneously creates a large in-memory representation and a complex DOM structure. This can lead to:

  • Slow initial load: Large DOM trees take longer to build and paint, delaying the initial view.
  • Performance bottlenecks: Scrolling through extensive lists becomes sluggish as the browser struggles to update the DOM for every item.
  • Memory issues: Extensive in-memory DOM representation can cause memory exhaustion, particularly on resource-constrained devices.

How Virtualization Works

Virtualization libraries leverage various techniques:

  • Windowing: They maintain a “window” representing the visible items based on the viewport size and scroll position. Only items within this window are rendered and updated.
  • Item recycling: Unused elements are recycled and repurposed for newly visible items, improving efficiency.
  • Lazy loading: Large datasets are loaded in chunks or on demand as the user scrolls, further reducing initial load times.

Benefits of Virtualization

  • Improved initial load time: Smaller initial DOM size leads to faster loading and rendering.
  • Smoother scrolling: Only visible items are updated, ensuring smooth scrolling performance.
  • Reduced memory footprint: Only necessary items are kept in memory, improving overall responsiveness.
  • Modular and reusability: Virtualization libraries offer reusable components for various list and grid scenarios.

Popular Virtualization Libraries

  • react-virtualized: Offers components like VirtualizedList, Grid, and Table for various scenarios.
  • react-window: Focuses on flexible windowing functionalities and performance optimizations.
  • react-grid-layout: Ideal for complex grid layouts with dynamic resizing and dragging functionalities.

Code Samples

Using react-virtualized for a large list

import { VirtualizedList } from 'react-virtualized';

const items = []; // Large array of items
const rowHeight = 50;

function App() {
return (
<VirtualizedList
height={300}
rowCount={items.length}
rowHeight={rowHeight}
renderItem={({ index, key, style }) => (
<div key={key} style={style}>
{items[index].name}
</div>
)}
/>
);
}

Using react-window for a customizable grid

import { FixedSizeList, WindowScroller } from 'react-window';

const itemSize = 100;

function Item({ index, style }) {
return <div style={style}>{index}</div>;
}

function App() {
return (
<WindowScroller>
{({ height, scrollTop }) => (
<FixedSizeList
height={height}
itemCount={1000}
itemSize={itemSize}
onItemsRendered={({ startIndex, stopIndex }) =>
console.log(`Rendered items: ${startIndex} - ${stopIndex}`)
}
>
{Item}
</FixedSizeList>
)}
</WindowScroller>
);
}

Technique 4 — Debounce and Throttle Events

When dealing with user interactions in React, particularly rapid events like typing or scrolling, excessive function calls can negatively impact performance. Debouncing and throttling are techniques to optimize how functions respond to rapid events, improving both user experience and application responsiveness.

  • Excessive function calls: Rapid user actions (e.g., typing, scrolling) can trigger frequent function calls. This can overwhelm the browser and lead to performance issues like lag, dropped frames, and unresponsive UI.
  • Network requests and expensive calculations: If these functions involve network requests or complex calculations, the overhead becomes further amplified.

Debouncing vs. Throttling

Debouncing

  • Delays the execution of a function until a specified period of inactivity has passed after the last event trigger.
  • Ensures the function is called only once for a series of rapid events, making it ideal for scenarios where the most recent value or action is relevant.
  • Example: Debouncing a search function during text input, ensuring the search is triggered only after the user finishes typing, not for every keystroke.

Throttling

  • Guarantees the execution of a function at most once within a defined time interval, regardless of how many events occur during that interval.
  • Useful for scenarios where regular updates are needed, but not at extremely high frequencies.
  • Example: Throttling a function that updates the scroll position of an element during scrolling, ensuring smooth updates without overwhelming the browser.

Implementation

  • Built-in libraries like lodash offer debounce and throttle functions.
  • Custom implementations can be created using timers and event handlers.
  • React hooks like useMemo or useCallback can help memoize calculations within debounced/throttled functions.

Code Samples

Debouncing a search function with lodash

import debounce from 'lodash/debounce';

const handleSearch = debounce((searchQuery) => {
// Perform search based on searchQuery
}, 500); // Wait 500ms after last keystroke

function SearchInput() {
const handleChange = (event) => {
const searchQuery = event.target.value;
handleSearch(searchQuery);
};

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

Throttling scroll position updates with custom implementation

const throttleScroll = (callback, delay) => {
let timer;
return (event) => {
if (!timer) {
timer = setTimeout(() => {
callback(event);
timer = null;
}, delay);
}
};
};

function App() {
const handleScroll = throttleScroll((event) => {
// Update scroll position based on event
}, 50);

return <div onScroll={handleScroll}>Long content...</div>;
}

Technique 5 — Image Optimization

Images often contribute significantly to a website’s weight, potentially impacting initial load times and perceived performance. In React applications, optimizing images effectively becomes crucial for delivering a smooth and seamless user experience.

  • Large image files take longer to download, slowing down initial page load and delaying content rendering.
  • Unoptimized images consume unnecessary bandwidth, especially on mobile networks with limited data plans.
  • Poorly optimized images can also impact rendering performance, as the browser needs to process them before displaying.

Optimization Techniques

Choose optimal image formats

  • Use modern formats like WebP (where supported) with smaller file sizes while maintaining quality.
  • Consider JPEG for photographs and PNG for graphics with transparency.

Compress images effectively

  • Utilize lossless or lossy compression techniques depending on the image type and acceptable quality reduction.
  • Explore online tools or libraries like imagemin for automated compression.

Resize images appropriately

  • Deliver images only at the required size displayed on the screen, avoiding unnecessary scaling and downloads.
  • Utilize server-side resizing or client-side libraries like react-responsive for dynamic resizing.

Lazy load images

  • Defer loading of images outside the viewport until they become visible, improving initial load times and perceived performance.
  • React libraries like react-lazyload or built-in mechanisms like loading="lazy" can facilitate this.

Utilize content delivery networks (CDNs)

  • Leverage CDNs to serve images from geographically distributed servers, reducing latency and improving loading times for users worldwide.

Code Samples

Using WebP format with react-responsive

import React, { useState } from 'react';
import Responsive from 'react-responsive';

const MyImage = ({ src, alt }) => {
const [webpSrc, setWebpSrc] = useState(null);

useEffect(() => {
// Replace src with WebP version if available
const webpUrl = src.replace(/\.(jpe?g|png)$/, '.webp');
fetch(webpUrl)
.then((response) => response.ok && setWebpSrc(webpUrl))
.catch(() => {});
}, [src]);

return (
<Responsive {...breakpoints}>
{({ isDesktop, isMobile }) => (
<img
src={webpSrc || src}
alt={alt}
width={isDesktop ? 800 : isMobile ? 300 : null}
/>
)}
</Responsive>
);
};

Lazy loading with react-lazyload

import React from 'react';
import LazyLoad from 'react-lazyload';

const MyLazyImage = ({ src, alt }) => {
return (
<LazyLoad once>
<img src={src} alt={alt} />
</LazyLoad>
);
};

That’s all about the 5 common optimization techniques for improving the performance of React applications. I hope this article has been of help to you.

--

--