React Performance Optimization Techniques

Some performance optimization techniques that can be used in React applications. Most of these techniques are not specific to React or any frontend libraries or framework, but they are worth considering wherever applicable when building frontend apps.

LIST VIRTUALIZATION

Most frontend applications render a dynamic list of some kind, dumping a huge list of data into the DOM is inefficient and can cause severe performance issues. List virtualization (windowing) can render only a subset of the list currently visible in the viewport, with more list items added to the viewport as the user scrolls. This ensures optimum performance by reducing the number of nodes added to the DOM during rendering while efficiently adding more list items on demand.

💡 React Virtualized and React Window are worth considering for an efficient implementation of list virtualization.

A basic implementation in React might look like this:

import React, { useState, useEffect, useRef } from "react";

const VirtualizedList = () => {
  // Sample data - in real usage this could be much larger
  const [items] = useState(Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`));

  // Configuration
  const itemHeight = 40; // Height of each list item in pixels
  const containerHeight = 400; // Height of the visible container
  const overscan = 3; // Number of items to render above/below visible area

  // State for scroll position and visible range
  const [scrollTop, setScrollTop] = useState(0);
  const containerRef = useRef(null);

  // Calculate visible range
  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
  const endIndex = Math.min(items.length, Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan);

  // Get visible items
  const visibleItems = items.slice(startIndex, endIndex);

  // Handle scroll events
  const handleScroll = (event) => {
    setScrollTop(event.target.scrollTop);
  };

  // Calculate total height of all items
  const totalHeight = items.length * itemHeight;

  // Calculate offset for current scroll position
  const offsetY = startIndex * itemHeight;

  return (
    <div className="border border-gray-200 rounded">
      <div ref={containerRef} className="overflow-auto" style={{ height: containerHeight }} onScroll={handleScroll}>
        <div className="relative w-full" style={{ height: totalHeight }}>
          <div className="absolute left-0 right-0" style={{ transform: `translateY(${offsetY}px)` }}>
            {visibleItems.map((item, index) => (
              <div
                key={startIndex + index}
                className="px-4 py-2 border-b border-gray-100 hover:bg-gray-50"
                style={{ height: itemHeight }}
              >
                {item}
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
};

export default VirtualizedList;

MEMOIZATION

Memoization is a technique used to cache the results of expensive computations or function calls. It is particularly useful when a function repeatedly receives the same input for the same operation. By memoizing a function, it only runs when the input values change, thereby reducing unnecessary executions. In React, this is implemented through React.memo, the useMemo Hook, and the useCallback Hook (which is particularly useful when passing functions as props to child components). Let's break down the main memoization tools in React:

  1. React.memo:
const MyComponent = React.memo(function MyComponent(props) {
  // render using props
});

This is for component-level memoization. It prevents re-renders if the props haven't changed. Useful for:

  • Components that render often with the same props
  • Components with expensive render logic
  • Pure presentational components
  1. useMemo Hook:
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

This memoizes values and is useful when:

  • You have expensive calculations
  • You're creating objects that should maintain referential equality
  • The computation depends on props or state that don't change every render
  1. useCallback Hook:
const memoizedCallback = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

This memoizes functions. Particularly important when:

  • Passing callbacks to optimized child components that rely on reference equality
  • Preventing infinite loops in useEffect
  • Working with event handlers that shouldn't change every render

A practical example of these memoization concepts:

function ExpensiveList({ items, onItemClick }) {
  // Memoize expensive calculation
  const sortedItems = useMemo(() => {
    return items.sort((a, b) => b.value - a.value);
  }, [items]);

  // Memoize callback
  const handleClick = useCallback(
    (id) => {
      onItemClick(id);
    },
    [onItemClick]
  );

  return (
    <ul>
      {sortedItems.map((item) => (
        <ListItem key={item.id} item={item} onClick={()=> handleClick(item.id)} />
      ))}
    </ul>
  );
}

// Memoize the entire component
const MemoizedExpensiveList = React.memo(ExpensiveList);

THROTTLING

Throttling is a technique used to limit how frequently a function or event handler can be invoked. It ensures that the function is called only at specified intervals, preventing excessive executions. For example, when detecting window resize events, if multiple invocations occur within the specified interval, the application only registers the first one and ignores subsequent calls until the interval has elapsed.

import React, { useState, useEffect } from "react";
import { throttle } from "lodash";

const WindowSizeDisplay = () => {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    // Update window size
    const handleResize = throttle(() => {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }, 250); // Throttle to once every 250ms

    // Set initial size
    handleResize();

    // Add event listener
    window.addEventListener("resize", handleResize);

    // Cleanup
    return () => {
      window.removeEventListener("resize", handleResize);
      handleResize.cancel();
    };
  }, []);

  return (
    <div className="w-64">
      <div className="text-center">
        <h2 className="text-xl font-semibold mb-4">Window Size</h2>
        <div className="space-y-2">
          <p className="text-gray-600">
            Width: <span className="font-medium">{windowSize.width}px</span>
          </p>
          <p className="text-gray-600">
            Height: <span className="font-medium">{windowSize.height}px</span>
          </p>
        </div>
      </div>
    </div>
  );
};

export default WindowSizeDisplay;

DEBOUNCING

Debouncing is a technique that delays the execution of a function until after a specified period of user inactivity. It's particularly useful for handling frequent events like typing in a search box. Without debouncing, functions might execute unnecessarily with every event (like every keystroke). For example, rather than making an API call after each keystroke as a user types, debouncing ensures the call only happens after the user has finished typing.

import React, { useState } from "react";
import { debounce } from "lodash";

function SearchBar() {
  const [searchResults, setSearchResults] = useState([]);

  // Create debounced search function
  const debouncedSearch = debounce(async (query) => {
    if (!query) {
      setSearchResults([]);
      return;
    }

    try {
      // Simulated API call
      console.log(`Searching for: ${query}`);
      // const response = await fetch(`/api/search?q=${query}`);
      // const data = await response.json();
      // setSearchResults(data);
    } catch (error) {
      console.error("Search failed:", error);
    }
  }, 500);

  const handleInputChange = (event) => {
    const query = event.target.value;
    debouncedSearch(query);
  };

  return (
    <div>
      <input type="text" placeholder="Search..." onChange={handleInputChange} className="border p-2 rounded" />
      {/* Display results */}
      <ul>
        {searchResults.map((result) => (
          <li key={result.id}>{result.title}</li>
        ))}
      </ul>
    </div>
  );
}

Key differences between debouncing and throttling:

  • Debouncing: Waits for a pause in activity before executing (good for search inputs, form submissions)
  • Throttling: Executes at regular intervals regardless of activity (good for scroll handlers, window resizing)

Some Common use cases for debouncing:

  1. Search input fields
  2. Form validation
  3. Window resize calculations
  4. Saving draft content
  5. API calls based on user input

CODE SPLITTING

Code Splitting is a technique used to break down large React/JavaScript bundles into smaller, more manageable chunks. Instead of loading the entire application code upfront, it allows you to load specific code pieces only when they're needed. This improves initial page load performance and reduces the amount of JavaScript that needs to be downloaded and parsed. The Bundle Analyzer tool can help visualize these code splits and identify opportunities for optimization.

Basic Route-based Code Splitting using React.lazy and Suspense:

import React, { Suspense, lazy } from "react";
const HeavyComponent = lazy(() => import("./HeavyComponent"));

export function MyComponent() {
  const [showHeavy, setShowHeavy] = useState(false);

  return (
    <div>
      <button onClick={()=> setShowHeavy(true)}>Load Heavy Component</button>

      {showHeavy && (
        <Suspense fallback={<div>Loading...</div>}>
          <HeavyComponent />
        </Suspense>
      )}
    </div>
  );
}