React Performance Optimization Guide

React Performance Optimization Guide

Advanced techniques for optimizing React applications including memoization, code splitting, and bundle optimization.

Frontend
January 20, 2024
9 min read
Zulfi Fadilah Azhar

React applications can become slow if not optimized properly. This comprehensive guide covers advanced techniques to make your React apps blazingly fast, from component optimization to bundle splitting strategies.

Understanding React Performance

Before optimizing, it's crucial to understand how React works:

  • Virtual DOM - React's diffing algorithm
  • Reconciliation - How React updates the DOM
  • Re-rendering - When and why components update

Measuring Performance

Use React DevTools Profiler to identify performance bottlenecks:

// Enable profiler in development
import { Profiler } from "react";

function onRenderCallback(id, phase, actualDuration) {
  console.log("Component:", id, "Phase:", phase, "Duration:", actualDuration);
}

function App() {
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      <MyComponent />
    </Profiler>
  );
}

Memoization Techniques

React.memo for Component Memoization

// Before optimization - re-renders on every parent update
const ExpensiveComponent = ({ data, theme }) => {
  const processedData = expensiveCalculation(data);

  return (
    <div className={theme}>
      {processedData.map((item) => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  );
};

// After optimization - only re-renders when props change
const OptimizedComponent = React.memo(({ data, theme }) => {
  const processedData = expensiveCalculation(data);

  return (
    <div className={theme}>
      {processedData.map((item) => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  );
});

// Custom comparison function for complex props
const OptimizedComponentWithComparison = React.memo(
  ({ data, theme }) => {
    // Component implementation
  },
  (prevProps, nextProps) => {
    return (
      prevProps.theme === nextProps.theme &&
      JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data)
    );
  }
);

useMemo for Expensive Calculations

import { useMemo, useState } from "react";

function ProductList({ products, filters }) {
  const [sortBy, setSortBy] = useState("name");

  // Expensive calculation memoized
  const filteredAndSortedProducts = useMemo(() => {
    console.log("Recalculating filtered products...");

    return products
      .filter((product) => {
        return Object.entries(filters).every(([key, value]) => {
          if (!value) return true;
          return product[key].toLowerCase().includes(value.toLowerCase());
        });
      })
      .sort((a, b) => {
        if (sortBy === "price") return a.price - b.price;
        if (sortBy === "name") return a.name.localeCompare(b.name);
        return 0;
      });
  }, [products, filters, sortBy]);

  return (
    <div>
      <select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
        <option value="name">Sort by Name</option>
        <option value="price">Sort by Price</option>
      </select>

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

useCallback for Function Memoization

import { useCallback, useState } from "react";

function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState("all");

  // Without useCallback - new function on every render
  const addTodo = (text) => {
    setTodos((prev) => [...prev, { id: Date.now(), text, completed: false }]);
  };

  // With useCallback - stable function reference
  const optimizedAddTodo = useCallback((text) => {
    setTodos((prev) => [...prev, { id: Date.now(), text, completed: false }]);
  }, []);

  const toggleTodo = useCallback((id) => {
    setTodos((prev) =>
      prev.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  }, []);

  const deleteTodo = useCallback((id) => {
    setTodos((prev) => prev.filter((todo) => todo.id !== id));
  }, []);

  return (
    <div>
      <TodoForm onAdd={optimizedAddTodo} />
      <TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />
    </div>
  );
}

// Child component won't re-render unnecessarily
const TodoForm = React.memo(({ onAdd }) => {
  const [text, setText] = useState("");

  const handleSubmit = (e) => {
    e.preventDefault();
    if (text.trim()) {
      onAdd(text.trim());
      setText("");
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Add todo..."
      />
      <button type="submit">Add</button>
    </form>
  );
});

Code Splitting and Lazy Loading

Component-Level Code Splitting

import { lazy, Suspense } from "react";

// Lazy load components
const Dashboard = lazy(() => import("./Dashboard"));
const Profile = lazy(() => import("./Profile"));
const Settings = lazy(() => import("./Settings"));

// Loading component
const LoadingSpinner = () => (
  <div className="loading-spinner">
    <div className="spinner"></div>
    <p>Loading...</p>
  </div>
);

function App() {
  return (
    <Router>
      <Routes>
        <Route
          path="/"
          element={
            <Suspense fallback={<LoadingSpinner />}>
              <Dashboard />
            </Suspense>
          }
        />
        <Route
          path="/profile"
          element={
            <Suspense fallback={<LoadingSpinner />}>
              <Profile />
            </Suspense>
          }
        />
        <Route
          path="/settings"
          element={
            <Suspense fallback={<LoadingSpinner />}>
              <Settings />
            </Suspense>
          }
        />
      </Routes>
    </Router>
  );
}

Dynamic Imports with Error Boundaries

import { Component, lazy, Suspense } from "react";

// Error boundary for handling loading errors
class LazyLoadErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error("Lazy loading error:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-fallback">
          <h2>Something went wrong loading this component.</h2>
          <button onClick={() => this.setState({ hasError: false })}>
            Try again
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

// Advanced lazy loading with retry mechanism
const createLazyComponent = (importFunc, retries = 3) => {
  return lazy(async () => {
    let attempt = 0;
    while (attempt < retries) {
      try {
        return await importFunc();
      } catch (error) {
        attempt++;
        if (attempt >= retries) throw error;
        await new Promise((resolve) => setTimeout(resolve, 1000));
      }
    }
  });
};

const HeavyComponent = createLazyComponent(() => import("./HeavyComponent"));

function App() {
  return (
    <LazyLoadErrorBoundary>
      <Suspense fallback={<LoadingSpinner />}>
        <HeavyComponent />
      </Suspense>
    </LazyLoadErrorBoundary>
  );
}

Virtualization for Large Lists

import { FixedSizeList as List } from "react-window";

// Virtualized list for thousands of items
function VirtualizedProductList({ products }) {
  const Row = ({ index, style }) => (
    <div style={style} className="product-row">
      <ProductCard product={products[index]} />
    </div>
  );

  return (
    <List height={600} itemCount={products.length} itemSize={120} width="100%">
      {Row}
    </List>
  );
}

// Infinite loading with virtualization
import InfiniteLoader from "react-window-infinite-loader";

function InfiniteProductList() {
  const [products, setProducts] = useState([]);
  const [hasMore, setHasMore] = useState(true);
  const [loading, setLoading] = useState(false);

  const loadMoreItems = useCallback(
    async (startIndex, stopIndex) => {
      if (loading) return;

      setLoading(true);
      try {
        const newProducts = await fetchProducts(startIndex, stopIndex);
        setProducts((prev) => [...prev, ...newProducts]);
        setHasMore(newProducts.length > 0);
      } catch (error) {
        console.error("Error loading products:", error);
      } finally {
        setLoading(false);
      }
    },
    [loading]
  );

  const isItemLoaded = (index) => !!products[index];

  const Row = ({ index, style }) => (
    <div style={style}>
      {isItemLoaded(index) ? (
        <ProductCard product={products[index]} />
      ) : (
        <div className="loading-placeholder">Loading...</div>
      )}
    </div>
  );

  return (
    <InfiniteLoader
      isItemLoaded={isItemLoaded}
      itemCount={hasMore ? products.length + 1 : products.length}
      loadMoreItems={loadMoreItems}
    >
      {({ onItemsRendered, ref }) => (
        <List
          ref={ref}
          height={600}
          itemCount={hasMore ? products.length + 1 : products.length}
          itemSize={120}
          onItemsRendered={onItemsRendered}
          width="100%"
        >
          {Row}
        </List>
      )}
    </InfiniteLoader>
  );
}

Image Optimization

// Lazy loading images with intersection observer
import { useState, useRef, useEffect } from "react";

function LazyImage({ src, alt, className, placeholder }) {
  const [loaded, setLoaded] = useState(false);
  const [inView, setInView] = useState(false);
  const imgRef = useRef();

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setInView(true);
          observer.disconnect();
        }
      },
      { threshold: 0.1 }
    );

    if (imgRef.current) {
      observer.observe(imgRef.current);
    }

    return () => observer.disconnect();
  }, []);

  return (
    <div ref={imgRef} className={className}>
      {inView && (
        <img
          src={src}
          alt={alt}
          onLoad={() => setLoaded(true)}
          style={{
            opacity: loaded ? 1 : 0,
            transition: "opacity 0.3s ease-in-out",
          }}
        />
      )}
      {!loaded && inView && (
        <div className="image-placeholder">{placeholder || "Loading..."}</div>
      )}
    </div>
  );
}

// Progressive image loading
function ProgressiveImage({ lowQualitySrc, highQualitySrc, alt }) {
  const [highQualityLoaded, setHighQualityLoaded] = useState(false);

  return (
    <div className="progressive-image">
      <img
        src={lowQualitySrc}
        alt={alt}
        className={`low-quality ${highQualityLoaded ? "fade-out" : ""}`}
      />
      <img
        src={highQualitySrc}
        alt={alt}
        className={`high-quality ${highQualityLoaded ? "fade-in" : ""}`}
        onLoad={() => setHighQualityLoaded(true)}
      />
    </div>
  );
}

Bundle Optimization

Webpack Bundle Analyzer

# Install webpack-bundle-analyzer
npm install --save-dev webpack-bundle-analyzer

# Add to package.json scripts
"analyze": "npm run build && npx webpack-bundle-analyzer build/static/js/*.js"

Tree Shaking and Dead Code Elimination

// ❌ Bad - imports entire library
import _ from "lodash";
import * as utils from "./utils";

// ✅ Good - import only what you need
import { debounce, throttle } from "lodash";
import { formatDate, validateEmail } from "./utils";

// ❌ Bad - creates unused code
const utils = {
  formatDate: (date) => date.toLocaleDateString(),
  formatTime: (date) => date.toLocaleTimeString(),
  formatDateTime: (date) => date.toLocaleString(),
  validateEmail: (email) => /\S+@\S+\.\S+/.test(email),
  validatePhone: (phone) => /^\d{10}$/.test(phone),
};

// ✅ Good - separate utility functions
export const formatDate = (date) => date.toLocaleDateString();
export const validateEmail = (email) => /\S+@\S+\.\S+/.test(email);

Performance Monitoring

// Custom hook for performance monitoring
import { useEffect, useRef } from "react";

function usePerformanceMonitor(componentName) {
  const renderCount = useRef(0);
  const startTime = useRef(performance.now());

  useEffect(() => {
    renderCount.current += 1;
    const endTime = performance.now();
    const renderTime = endTime - startTime.current;

    console.log(
      `${componentName} - Render #${
        renderCount.current
      } - Time: ${renderTime.toFixed(2)}ms`
    );

    startTime.current = performance.now();
  });

  useEffect(() => {
    return () => {
      console.log(
        `${componentName} unmounted after ${renderCount.current} renders`
      );
    };
  }, [componentName]);
}

// Usage
function MyComponent() {
  usePerformanceMonitor("MyComponent");

  // Component logic
  return <div>My Component</div>;
}

Best Practices Summary

  1. Profile first, optimize second - Use React DevTools
  2. Avoid premature optimization - Focus on actual bottlenecks
  3. Use React.memo wisely - Don't wrap every component
  4. Memoize expensive calculations - useMemo for heavy computations
  5. Stabilize function references - useCallback for event handlers
  6. Implement code splitting - Lazy load route components
  7. Virtualize large lists - Don't render thousands of DOM nodes
  8. Optimize images - Lazy loading and progressive enhancement
  9. Monitor bundle size - Regular analysis and optimization
  10. Measure performance - Track metrics in production

Common Performance Anti-Patterns

// ❌ Anti-pattern: Creating objects in render
function BadComponent({ items }) {
  return (
    <div>
      {items.map((item) => (
        <ItemComponent
          key={item.id}
          item={item}
          style={{ padding: "10px" }} // New object every render!
        />
      ))}
    </div>
  );
}

// ✅ Good: Stable object references
const itemStyle = { padding: "10px" };

function GoodComponent({ items }) {
  return (
    <div>
      {items.map((item) => (
        <ItemComponent key={item.id} item={item} style={itemStyle} />
      ))}
    </div>
  );
}

Conclusion

React performance optimization is about understanding when and why components re-render, then applying the right techniques to minimize unnecessary work. Start with measuring, identify bottlenecks, and apply these optimization techniques strategically.

Remember: fast enough is often better than perfectly optimized. Focus on the user experience and measure the impact of your optimizations! ⚡

Tags

ReactPerformanceOptimizationJavaScript