DailyDevDiet

logo - dailydevdiet

Learn. Build. Innovate. Elevate your coding skills with dailydevdiet!

Chapter 13: Optimization and Performance

Optimization and Performance

Introduction

As React applications grow in complexity, optimization and performance becomes increasingly important. A poorly optimized React application can lead to slow rendering, unresponsive UI, and a frustrating user experience. This chapter covers various techniques and best practices to optimize your React applications and ensure they perform well even as they scale.

Why Performance Matters

Before diving into optimization techniques, it’s important to understand why performance matters:

  • User Experience: Users expect web applications to be fast and responsive
  • Engagement: Slower sites have higher bounce rates and lower conversion rates
  • SEO: Page speed is a ranking factor for search engines
  • Mobile Experience: Performance issues are amplified on mobile devices with limited resources

Common Performance Issues in React

React applications can suffer from several performance issues:

  1. Unnecessary Re-renders: Components re-rendering when they don’t need to
  2. Large Bundle Sizes: Sending too much JavaScript to the client
  3. Inefficient State Updates: Poor state management causing cascading updates
  4. Expensive Calculations: Performing heavy computations in render cycles
  5. DOM Manipulations: Excessive updates to the actual DOM

Identifying Performance Problems

Before optimizing, you need to identify where the performance issues are:

React DevTools Profiler

The React DevTools Profiler is an essential tool for identifying performance issues:

// First, install React DevTools extension in your browser
// Then in development mode, you can use the Profiler tab

// Record a session and analyze:
// - Which components are rendering
// - How long each component takes to render
// - What caused components to re-render

Performance Monitoring

// You can use the built-in Performance API
const t0 = performance.now();
// Do something...
const t1 = performance.now();
console.log(`Operation took ${t1 - t0} milliseconds`);

Preventing Unnecessary Re-renders

Using React.memo

React.memo is a higher-order component that memoizes your component, preventing it from re-rendering if props haven’t changed:

import React from 'react';

// Without memoization - will re-render even if props don't change
function RegularComponent({ name }) {
  console.log('RegularComponent rendered');
  return <div>Hello, {name}!</div>;
}

// With memoization - will only re-render if props change
const MemoizedComponent = React.memo(function MemoizedComponent({ name }) {
  console.log('MemoizedComponent rendered');
  return <div>Hello, {name}!</div>;
});

// Usage
function App() {
  const [count, setCount] = useState(0);
 
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Clicked {count} times
      </button>
      <RegularComponent name="John" />      {/* Re-renders on every click */}
      <MemoizedComponent name="Jane" />     {/* Only renders once */}
    </div>
  );
}

Custom Comparison Function with React.memo

You can provide a custom comparison function to React.memo:

import React from 'react';

function MovieList({ movies, sortOrder }) {
  console.log('MovieList rendered');
  // Rendering logic...
}

// Only re-render if the actual movie data changes, not just sortOrder
const MemoizedMovieList = React.memo(
  MovieList,
  (prevProps, nextProps) => {
    // Return true if we should NOT re-render
    return (
      prevProps.movies === nextProps.movies &&
      prevProps.sortOrder.field === nextProps.sortOrder.field
    );
  }
);

PureComponent for Class Components

If you’re still using class components, PureComponent provides similar functionality:

import React, { PureComponent } from 'react';

// Regular component - will re-render on every parent render
class RegularComponent extends React.Component {
  render() {
    console.log('RegularComponent rendered');
    return <div>Hello, {this.props.name}!</div>;
  }
}

// Pure component - will only re-render if props or state change
class OptimizedComponent extends PureComponent {
  render() {
    console.log('OptimizedComponent rendered');
    return <div>Hello, {this.props.name}!</div>;
  }
}

Optimizing Hooks

useMemo for Expensive Calculations

useMemo allows you to memoize expensive calculations, so they’re only recomputed when dependencies change:

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

function ExpensiveCalculationComponent({ data }) {
  const [count, setCount] = useState(0);
 
  // Without useMemo - recalculates on every render
  // const processedData = processData(data); // Expensive operation
 
  // With useMemo - only recalculates when data changes
  const processedData = useMemo(() => {
    console.log('Processing data...');
    return processData(data); // Expensive operation
  }, [data]);
 
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Clicked {count} times
      </button>
      <div>Processed Result: {processedData.length}</div>
    </div>
  );
}

function processData(data) {
  // Simulate expensive operation
  let result = [];
  for (let i = 0; i < 1000000; i++) {
    if (i % 10000 === 0) result.push(data * i);
  }
  return result;
}

useCallback for Memoizing Functions

useCallback is similar to useMemo but is specifically for memoizing functions:

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

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');
 
  // Without useCallback - creates a new function reference on every render
  // const handleClick = () => {
  //   console.log('Button clicked');
  // };
 
  // With useCallback - maintains the same function reference
  const handleClick = useCallback(() => {
    console.log('Button clicked');
  }, []); // Empty array means this function never changes
 
  // With dependencies - function reference changes when dependencies change
  const handleTextChange = useCallback((e) => {
    setText(e.target.value);
  }, []);
 
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Parent count: {count}
      </button>
      <input value={text} onChange={handleTextChange} />
      <ChildComponent onClick={handleClick} />
    </div>
  );
}

// This child will only re-render when props change
const ChildComponent = React.memo(function ChildComponent({ onClick }) {
  console.log('ChildComponent rendered');
  return <button onClick={onClick}>Click me!</button>;
});

Code Splitting and Lazy Loading

React.lazy and Suspense

Using React.lazy and Suspense allows you to split your code into smaller chunks and load components only when needed:

import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

// Instead of importing directly
// import HomePage from './HomePage';
// import AboutPage from './AboutPage';
// import ContactPage from './ContactPage';

// Use lazy loading
const HomePage = lazy(() => import('./HomePage'));
const AboutPage = lazy(() => import('./AboutPage'));
const ContactPage = lazy(() => import('./ContactPage'));

function App() {
  return (
    <Router>
      <Suspense fallback={<div>Loading...</div>}>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/about" element={<AboutPage />} />
          <Route path="/contact" element={<ContactPage />} />
        </Routes>
      </Suspense>
    </Router>
  );
}

Dynamic Imports

You can also use dynamic imports for non-component code:

// Instead of importing at the top
// import { heavyCalculation } from './utils';

function MyComponent() {
  const [result, setResult] = useState(null);
 
  const handleCalculate = async () => {
    // Import only when needed
    const { heavyCalculation } = await import('./utils');
    const calculationResult = heavyCalculation();
    setResult(calculationResult);
  };
 
  return (
    <div>
      <button onClick={handleCalculate}>Calculate</button>
      {result && <div>Result: {result}</div>}
    </div>
  );
}

Virtualization for Long Lists

When rendering long lists, virtualization can drastically improve performance by only rendering items that are currently visible:

jsx
import React from 'react';
import { FixedSizeList as List } from 'react-window';

function VirtualizedList({ items }) {
  // Row renderer function
  const Row = ({ index, style }) => (
    <div style={style}>
      Item {items[index].name}
    </div>
  );
 
  return (
    <List
      height={400} // Fixed height of the list
      width="100%"
      itemCount={items.length}
      itemSize={35} // Height of each item
    >
      {Row}
    </List>
  );
}

// Usage
function App() {
  // Generate 10,000 items
  const items = Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `Item ${i}` }));
 
  return (
    <div>
      <h1>Virtualized List Example</h1>
      <VirtualizedList items={items} />
    </div>
  )

Popular virtualization libraries include:

  • react-window (lightweight)
  • react-virtualized (feature-rich)

Bundle Size Optimization

Analyzing Your Bundle

You can use tools like webpack-bundle-analyzer to visualize your bundle size:

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

# Add to webpack config
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}

Tree Shaking

Tree shaking is a technique to remove unused code. It’s enabled by default in modern bundlers like Webpack and Rollup, but requires you to use ES modules:

// Bad - imports the entire library
import moment from 'moment';

// Good - imports only what you need
import { format } from 'date-fns';

Using Smaller Alternatives

Consider using smaller alternatives for large libraries:

// Instead of moment.js (large)
// import moment from 'moment';
// const formattedDate = moment().format('YYYY-MM-DD');

// Use date-fns (smaller, tree-shakable)
import { format } from 'date-fns';
const formattedDate = format(new Date(), 'yyyy-MM-dd');

// Or even native JavaScript Date
const date = new Date();
const formattedDateNative = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;

Debouncing and Throttling

For events that fire frequently (like scrolling, resizing, or typing), debouncing and throttling can improve performance:

import React, { useState, useEffect, useCallback } from 'react';
import { debounce, throttle } from 'lodash';

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
 
  // Without debounce - API call on every keystroke
  // const handleChange = (e) => {
  //   setQuery(e.target.value);
  //   searchAPI(e.target.value);
  // };
 
  // With debounce - API call 300ms after user stops typing
  const debouncedSearch = useCallback(
    debounce((searchTerm) => {
      console.log('Searching for:', searchTerm);
      // Simulate API call
      fetch(`/api/search?q=${searchTerm}`)
        .then(res => res.json())
        .then(data => setResults(data));
    }, 300),
    []
  );
 
  const handleChange = (e) => {
    setQuery(e.target.value);
    debouncedSearch(e.target.value);
  };
 
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={handleChange}
        placeholder="Search..."
      />
      <ul>
        {results.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

// Example with throttle for scroll events
function ScrollTracker() {  
  const [scrollPosition, setScrollPosition] = useState(0);    
  // Throttle to execute at most once every 100ms  
  const handleScroll = useCallback(throttle(() => {      
    const position = window.scrollY;      
    setScrollPosition(position);      
    console.log('Scroll position:', position);    
  }, 100),  []);    
  useEffect(() => {    
    window.addEventListener('scroll', handleScroll);   
    return () => {      
    window.removeEventListener('scroll', handleScroll);      
    handleScroll.cancel(); 
    // Cancel any pending execution    
  };  
  }, [handleScroll]);    
  return (    
    <div style={{ height: '2000px' }}>      
      <div style={{ position: 'fixed', top: 0, left: 0 }}>        Scroll Position: {scrollPosition}px      
      </div>    
  </div>  
  );
}

Web Workers for CPU-Intensive Tasks

For heavy calculations that might block the main thread, consider using Web Workers:

// worker.js
self.onmessage = function(e) {
  const { data, operation } = e.data;
 
  if (operation === 'process') {
    // Perform expensive calculation
    const result = performExpensiveCalculation(data);
    self.postMessage({ result });
  }
};

function performExpensiveCalculation(data) {
  // Simulate heavy processing
  let result = 0;
  for (let i = 0; i < 1000000000; i++) {
    result += i * data;
    if (i % 1000000 === 0) result = result % 10000;
  }
  return result;
}

// Component using web worker
import React, { useState } from 'react';

function WebWorkerComponent() {
  const [result, setResult] = useState(null);
  const [isCalculating, setIsCalculating] = useState(false);
 
  const handleCalculate = () => {
    setIsCalculating(true);
   
    // Create worker
    const worker = new Worker('./worker.js');
   
    // Handle worker response
    worker.onmessage = function(e) {
      setResult(e.data.result);
      setIsCalculating(false);
      worker.terminate(); // Clean up
    };
   
    // Start the calculation
    worker.postMessage({ data: 42, operation: 'process' });
  };
 
  return (
    <div>
      <button onClick={handleCalculate} disabled={isCalculating}>
        {isCalculating ? 'Calculating...' : 'Start Heavy Calculation'}
      </button>
      {result !== null && <div>Result: {result}</div>}
    </div>
  );
}

Memory Management and Preventing Leaks

Memory leaks can severely impact performance over time:

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

function LeakyComponent() {
  const [data, setData] = useState(null);
 
  useEffect(() => {
    // BAD: This event listener is never removed
    // window.addEventListener('resize', () => {
    //   console.log('Window resized');
    //   // Do something with data
    // });
   
    // GOOD: Clean up event listeners
    const handleResize = () => {
      console.log('Window resized');
      // Do something with data
    };
   
    window.addEventListener('resize', handleResize);
   
    // Return cleanup function
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);
 
  return <div>Component with proper cleanup</div>;
}

Common sources of memory leaks in React:

  1. Forgotten event listeners: Always remove event listeners in the cleanup function
  2. Timers and intervals: Clear all timers and intervals in the cleanup function
  3. Subscriptions: Unsubscribe from all subscriptions when the component unmounts
  4. Refs to DOM elements: Set refs to null in the cleanup function
  5. Closures capturing values: Be careful with closures that capture stale values

Optimizing Context API Usage

Overusing Context can lead to performance issues:

import React, { createContext, useContext, useState } from 'react';

// BAD: One big context for everything
// const AppContext = createContext();
//
// function AppProvider({ children }) {
//   const [user, setUser] = useState(null);
//   const [theme, setTheme] = useState('light');
//   const [notifications, setNotifications] = useState([]);
//  
//   return (
//     <AppContext.Provider value={{ user, setUser, theme, setTheme, notifications, setNotifications }}>
//       {children}
//     </AppContext.Provider>
//   );
// }

// GOOD: Separate contexts for different concerns
const UserContext = createContext();
const ThemeContext = createContext();
const NotificationContext = createContext();

function AppProvider({ children }) {
  return (
    <UserProvider>
      <ThemeProvider>
        <NotificationProvider>
          {children}
        </NotificationProvider>
      </ThemeProvider>
    </UserProvider>
  );
}

function UserProvider({ children }) {
  const [user, setUser] = useState(null);
  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
}

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

function NotificationProvider({ children }) {
  const [notifications, setNotifications] = useState([]);
  return (
    <NotificationContext.Provider value={{ notifications, setNotifications }}>
      {children}
    </NotificationContext.Provider>
  );
}

// Usage
function UserProfile() {
  // Only re-renders when user changes
  const { user } = useContext(UserContext);
  return <div>{user?.name}</div>;
}

function ThemeToggle() {
  // Only re-renders when theme changes
  const { theme, setTheme } = useContext(ThemeContext);
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Toggle Theme
    </button>
  );
}

Image Optimization

Optimizing images can significantly improve performance:

import React from 'react';

function OptimizedImage({ src, alt, width, height }) {
  return (
    <div>
      {/* Responsive images */}
      <img
        src={src}
        alt={alt}
        loading="lazy" // Lazy loading
        width={width}
        height={height}
        style={{ maxWidth: '100%', height: 'auto' }}
        srcSet={`
          ${src.replace('.jpg', '-small.jpg')} 400w,
          ${src.replace('.jpg', '-medium.jpg')} 800w,
          ${src} 1200w
        `}
        sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
      />
    </div>
  );
}

Image optimization best practices:

  1. Use appropriate formats: WebP for most images, SVG for icons and simple graphics
  2. Responsive images: Use srcset and sizes attributes
  3. Lazy loading: Use the loading=”lazy” attribute
  4. Image compression: Compress images before serving
  5. Serve images from a CDN: Reduces latency

CSS Optimization

CSS can also impact performance:

// Bad: Inline styles created on every render
function BadExample() {
  return (
    <div style={{
      color: 'red',
      padding: '20px',
      backgroundColor: '#f5f5f5'
    }}>
      This has inline styles
    </div>
  );
}

// Better: Use CSS modules or styled-components
import styles from './styles.module.css';

function BetterExample() {
  return (
    <div className={styles.container}>
      This uses CSS modules
    </div>
  );
}

CSS optimization best practices:

  1. Avoid inline styles: They cause unnecessary re-renders
  2. Use CSS-in-JS with caution: Some implementations can impact performance
  3. Consider CSS Modules: Scoped CSS without runtime overhead
  4. Minimize CSS rules: Remove unused styles
  5. Prefer CSS classes over inline styles

Performance Testing

Regular performance testing is essential for maintaining a fast application:

Lighthouse

Google’s Lighthouse is a valuable tool for performance testing:

# Install globally
npm install -g lighthouse

# Run a test
lighthouse https://your-site.com --view

React Performance Testing Tools

// Using the Profiler API for measuring performance
import React, { Profiler } from 'react';

function onRenderCallback(
  id, // The "id" prop of the Profiler tree
  phase, // "mount" (first render) or "update" (re-render)
  actualDuration, // Time spent rendering
  baseDuration, // Estimated time for the entire subtree without memoization
  startTime, // When React began rendering this update
  commitTime, // When React committed this update
  interactions // The Set of interactions that were being traced when this update was scheduled
) {
  console.log(`Component ${id} rendered in ${actualDuration}ms`);
}

function App() {

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

Best Practices Summary

  1. Measure first: Always identify performance issues before optimizing
  2. Prevent unnecessary re-renders: Use React.memo, useMemo, and useCallback
  3. Code splitting: Lazy load components and routes
  4. Virtualize long lists: Only render what’s visible
  5. Optimize bundle size: Analyze and reduce your JavaScript bundle
  6. Debounce and throttle: For frequent events like scrolling and typing
  7. Web Workers: For CPU-intensive tasks
  8. Memory management: Prevent leaks by cleaning up resources
  9. Context optimization: Split contexts by concern and minimize consumers
  10. Asset optimization: Optimize images, fonts, and other assets

Conclusion

Performance optimization is an ongoing process. By implementing the techniques covered in this chapter, you can significantly improve the performance of your React applications. Remember to always measure performance before and after optimizations to ensure your changes have the intended effect. The React DevTools and browser performance tools are invaluable for this purpose.

Additional Resources

Scroll to Top