Back to Blog Posts

React Performance Optimization: From Basics to Advanced Techniques

9 min read
ReactPerformanceJavaScriptWeb Development

Introduction

React applications can become slow as they grow in complexity. This comprehensive guide covers performance optimization techniques from basic memoization to advanced strategies like virtualization and code splitting.

Understanding React's Rendering Behavior

Before diving into optimizations, it's crucial to understand how React renders components.

The Rendering Process

// Every state change triggers a re-render
function App() {
  const [count, setCount] = useState(0);
  
  // This component and all its children re-render when count changes
  return (
    <div>
      <Counter count={count} />
      <ExpensiveComponent /> {/* Re-renders even if it doesn't use count */}
    </div>
  );
}

React DevTools Profiler

The React DevTools Profiler is your best friend for identifying performance issues.

// Wrap components to measure performance
import { Profiler } from 'react';

function onRenderCallback(id, phase, actualDuration) {
  console.log(`${id} (${phase}) took ${actualDuration}ms`);
}

<Profiler id="Navigation" onRender={onRenderCallback}>
  <Navigation />
</Profiler>

Memoization Techniques

React.memo()

Prevent unnecessary re-renders by memoizing components:

// Without memo - re-renders on every parent render
const ExpensiveList = ({ items }) => {
  console.log('ExpensiveList rendered');
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
};

// With memo - only re-renders when props change
const MemoizedList = React.memo(({ items }) => {
  console.log('MemoizedList rendered');
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
});

// Custom comparison function
const MemoizedListWithCompare = React.memo(
  ExpensiveList,
  (prevProps, nextProps) => {
    // Return true if props are equal (skip re-render)
    return prevProps.items.length === nextProps.items.length;
  }
);

useMemo()

Memoize expensive computations:

function DataProcessor({ data, filter }) {
  // Without useMemo - recalculates on every render
  const processedData = data
    .filter(item => item.category === filter)
    .map(item => ({
      ...item,
      processed: heavyCalculation(item)
    }));
  
  // With useMemo - only recalculates when dependencies change
  const optimizedData = useMemo(() => {
    return data
      .filter(item => item.category === filter)
      .map(item => ({
        ...item,
        processed: heavyCalculation(item)
      }));
  }, [data, filter]);
  
  return <DataView data={optimizedData} />;
}

useCallback()

Prevent function recreation and unnecessary child re-renders:

function TodoList({ todos }) {
  const [filter, setFilter] = useState('all');
  
  // Without useCallback - new function on every render
  const handleDelete = (id) => {
    // delete logic
  };
  
  // With useCallback - stable function reference
  const optimizedDelete = useCallback((id) => {
    // delete logic
  }, []); // No dependencies = never recreated
  
  const handleToggle = useCallback((id) => {
    setTodos(prev => prev.map(todo => 
      todo.id === id ? { ...todo, done: !todo.done } : todo
    ));
  }, []); // setTodos is stable, so no dependencies needed
  
  return (
    <>
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onDelete={optimizedDelete}
          onToggle={handleToggle}
        />
      ))}
    </>
  );
}

// TodoItem can be memoized effectively now
const TodoItem = React.memo(({ todo, onDelete, onToggle }) => {
  return (
    <div>
      <span>{todo.text}</span>
      <button onClick={() => onToggle(todo.id)}>Toggle</button>
      <button onClick={() => onDelete(todo.id)}>Delete</button>
    </div>
  );
});

State Management Optimization

State Colocation

Keep state as close to where it's used as possible:

// Bad - App re-renders when form state changes
function App() {
  const [formData, setFormData] = useState({});
  
  return (
    <div>
      <Header />
      <MainContent />
      <ContactForm 
        formData={formData}
        setFormData={setFormData}
      />
    </div>
  );
}

// Good - Only ContactForm re-renders
function App() {
  return (
    <div>
      <Header />
      <MainContent />
      <ContactForm />
    </div>
  );
}

function ContactForm() {
  const [formData, setFormData] = useState({});
  // Form logic here
}

State Splitting

Split state to minimize re-render scope:

// Bad - Everything re-renders when any state changes
function Dashboard() {
  const [state, setState] = useState({
    user: null,
    posts: [],
    notifications: [],
    settings: {}
  });
  
  return (
    <>
      <UserProfile user={state.user} />
      <PostList posts={state.posts} />
      <NotificationBell notifications={state.notifications} />
      <Settings settings={state.settings} />
    </>
  );
}

// Good - Components only re-render when their data changes
function Dashboard() {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [notifications, setNotifications] = useState([]);
  const [settings, setSettings] = useState({});
  
  return (
    <>
      <UserProfile user={user} />
      <PostList posts={posts} />
      <NotificationBell notifications={notifications} />
      <Settings settings={settings} />
    </>
  );
}

List Virtualization

For large lists, render only visible items:

import { FixedSizeList } from 'react-window';

function VirtualizedList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      {items[index].name}
    </div>
  );
  
  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={50}
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

// Advanced: Variable size list with dynamic content
import { VariableSizeList } from 'react-window';

function DynamicVirtualList({ items }) {
  const listRef = useRef();
  const rowHeights = useRef({});
  
  const getItemSize = useCallback((index) => {
    return rowHeights.current[index] || 100; // Default height
  }, []);
  
  const Row = ({ index, style }) => {
    const rowRef = useRef();
    
    useEffect(() => {
      if (rowRef.current) {
        const height = rowRef.current.getBoundingClientRect().height;
        if (rowHeights.current[index] !== height) {
          rowHeights.current[index] = height;
          listRef.current.resetAfterIndex(index);
        }
      }
    }, [index]);
    
    return (
      <div ref={rowRef} style={style}>
        <ItemComponent item={items[index]} />
      </div>
    );
  };
  
  return (
    <VariableSizeList
      ref={listRef}
      height={600}
      itemCount={items.length}
      itemSize={getItemSize}
      width="100%"
    >
      {Row}
    </VariableSizeList>
  );
}

Code Splitting and Lazy Loading

Route-based Code Splitting

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

// Lazy load route components
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));

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

Component-based Code Splitting

// Lazy load heavy components
const HeavyChart = lazy(() => import('./components/HeavyChart'));

function Analytics({ data }) {
  const [showChart, setShowChart] = useState(false);
  
  return (
    <div>
      <button onClick={() => setShowChart(true)}>
        Show Chart
      </button>
      
      {showChart && (
        <Suspense fallback={<div>Loading chart...</div>}>
          <HeavyChart data={data} />
        </Suspense>
      )}
    </div>
  );
}

// Progressive enhancement with error boundaries
import { ErrorBoundary } from 'react-error-boundary';

function AnalyticsWithErrorHandling({ data }) {
  return (
    <ErrorBoundary
      fallback={<div>Failed to load chart</div>}
      onError={(error) => console.error('Chart loading error:', error)}
    >
      <Analytics data={data} />
    </ErrorBoundary>
  );
}

Image Optimization

Lazy Loading Images

function LazyImage({ src, alt, ...props }) {
  const [imageSrc, setImageSrc] = useState(null);
  const [imageRef, setImageRef] = useState();
  const [isIntersecting, setIsIntersecting] = useState(false);

  useEffect(() => {
    let observer;
    
    if (imageRef) {
      observer = new IntersectionObserver(
        entries => {
          entries.forEach(entry => {
            if (entry.isIntersecting) {
              setIsIntersecting(true);
              observer.unobserve(imageRef);
            }
          });
        },
        { threshold: 0.1 }
      );
      observer.observe(imageRef);
    }
    
    return () => {
      if (observer) observer.disconnect();
    };
  }, [imageRef]);

  useEffect(() => {
    if (isIntersecting) {
      const img = new Image();
      img.src = src;
      img.onload = () => setImageSrc(src);
    }
  }, [isIntersecting, src]);

  return (
    <div ref={setImageRef} {...props}>
      {imageSrc ? (
        <img src={imageSrc} alt={alt} />
      ) : (
        <div className="placeholder" />
      )}
    </div>
  );
}

Responsive Images

function ResponsiveImage({ src, alt, sizes }) {
  const generateSrcSet = () => {
    const widths = [320, 640, 960, 1280, 1920];
    return widths
      .map(w => `${src}?w=${w} ${w}w`)
      .join(', ');
  };
  
  return (
    <img
      src={`${src}?w=1280`}
      srcSet={generateSrcSet()}
      sizes={sizes || "(max-width: 640px) 100vw, (max-width: 960px) 50vw, 33vw"}
      alt={alt}
      loading="lazy"
    />
  );
}

Debouncing and Throttling

Search Input Debouncing

import { useMemo, useState, useEffect } from 'react';
import debounce from 'lodash/debounce';

function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState([]);
  
  const debouncedSearch = useMemo(
    () => debounce(async (term) => {
      if (term) {
        const data = await searchAPI(term);
        setResults(data);
      }
    }, 300),
    []
  );
  
  useEffect(() => {
    debouncedSearch(searchTerm);
    
    return () => {
      debouncedSearch.cancel();
    };
  }, [searchTerm, debouncedSearch]);
  
  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search..."
      />
      <SearchResults results={results} />
    </div>
  );
}

Scroll Event Throttling

function useScrollPosition() {
  const [scrollPosition, setScrollPosition] = useState(0);
  
  useEffect(() => {
    const updatePosition = throttle(() => {
      setScrollPosition(window.pageYOffset);
    }, 100);
    
    window.addEventListener('scroll', updatePosition);
    return () => window.removeEventListener('scroll', updatePosition);
  }, []);
  
  return scrollPosition;
}

// Custom throttle implementation
function throttle(func, limit) {
  let inThrottle;
  return function(...args) {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
    }
  }
}

Bundle Size Optimization

Tree Shaking and Import Optimization

// Bad - imports entire library
import _ from 'lodash';
const result = _.debounce(fn, 300);

// Good - imports only what's needed
import debounce from 'lodash/debounce';
const result = debounce(fn, 300);

// Even better - use ES modules
import { debounce } from 'lodash-es';

// For libraries like date-fns
import { format, parseISO } from 'date-fns';
// Instead of
import * as dateFns from 'date-fns';

Dynamic Imports for Large Libraries

function ChartComponent({ data, type }) {
  const [Chart, setChart] = useState(null);
  
  useEffect(() => {
    const loadChart = async () => {
      const { default: ChartModule } = await import('chart.js/auto');
      setChart(() => ChartModule);
    };
    
    loadChart();
  }, []);
  
  if (!Chart) return <div>Loading chart library...</div>;
  
  return <Chart data={data} type={type} />;
}

Performance Monitoring

Custom Performance Hook

function useComponentPerformance(componentName) {
  useEffect(() => {
    const startTime = performance.now();
    
    return () => {
      const endTime = performance.now();
      const renderTime = endTime - startTime;
      
      if (renderTime > 16) { // More than one frame (60fps)
        console.warn(
          `${componentName} took ${renderTime.toFixed(2)}ms to render`
        );
      }
      
      // Send to analytics
      if (window.analytics) {
        window.analytics.track('Component Performance', {
          component: componentName,
          renderTime: renderTime,
          timestamp: new Date().toISOString()
        });
      }
    };
  }, [componentName]);
}

// Usage
function ExpensiveComponent() {
  useComponentPerformance('ExpensiveComponent');
  
  // Component logic
  return <div>...</div>;
}

Best Practices Checklist

  1. Profile Before Optimizing: Use React DevTools Profiler to identify actual bottlenecks
  2. Measure Impact: Track performance metrics before and after optimizations
  3. Start Simple: Begin with basic optimizations like React.memo() before complex solutions
  4. Avoid Premature Optimization: Not every component needs memoization
  5. Consider User Experience: Sometimes perceived performance is more important than actual performance

Common Pitfalls

Over-memoization

// Don't memoize everything
const SimpleComponent = React.memo(({ text }) => {
  return <span>{text}</span>; // Too simple to benefit from memoization
});

// Memoize when it makes sense
const ExpensiveComponent = React.memo(({ data }) => {
  const processed = expensiveCalculation(data);
  return <ComplexVisualization data={processed} />;
});

Incorrect Dependency Arrays

// Bug: Missing dependency
useEffect(() => {
  fetchData(userId); // userId should be in deps
}, []); // Missing userId

// Correct
useEffect(() => {
  fetchData(userId);
}, [userId]);

// Use ESLint plugin for exhaustive deps
// npm install eslint-plugin-react-hooks

Conclusion

React performance optimization is about finding the right balance. Not every component needs optimization, but knowing these techniques helps you make informed decisions when performance issues arise.

Remember: measure first, optimize second, and always consider the trade-offs between performance gains and code complexity. Happy optimizing!