← All insights

Optimizing React Application Performance

React is fast by default, but as your application grows, performance bottlenecks can emerge. Understanding React's rendering behavior and applying optimization techniques strategically can dramatically improve your application's performance. This guide covers practical techniques that make a real difference in production applications.

Understanding React Rendering

Before optimizing, you need to understand when and why React re-renders components:

  • State changes in the component
  • Props changes from parent
  • Parent component re-renders (by default, all children re-render)
  • Context value changes

The Golden Rule

Don't optimize prematurely. Profile first, identify real bottlenecks, then optimize. Most React apps don't need heavy optimization until they scale significantly.

React.memo for Component Memoization

React.memo prevents unnecessary re-renders by memoizing component output based on props.

// Without memoization - re-renders on every parent render
function ExpensiveComponent({ data, onClick }) {
  console.log('Rendering ExpensiveComponent');
  return <div onClick={onClick}>{data.map(item => item.name)}</div>;
}

// With memoization - only re-renders when props change
const ExpensiveComponent = React.memo(({ data, onClick }) => {
  console.log('Rendering ExpensiveComponent');
  return <div onClick={onClick}>{data.map(item => item.name)}</div>;
});

// Custom comparison function
const ExpensiveComponent = React.memo(
  ({ data, onClick }) => {
    return <div onClick={onClick}>{data.map(item => item.name)}</div>;
  },
  (prevProps, nextProps) => {
    // Return true if props are equal (skip render)
    return prevProps.data.length === nextProps.data.length;
  }
);

When to Use React.memo

  • Component renders often with same props
  • Component has expensive rendering logic
  • Component receives large data structures as props
  • Pure presentational components

When NOT to Use React.memo

  • Props change frequently anyway
  • Component is already fast
  • Component renders rarely
  • Props contain functions or objects created inline

useMemo for Expensive Calculations

useMemo memoizes the result of expensive computations.

import { useMemo } from 'react';

function DataTable({ data, filterText }) {
  // Without useMemo - recalculates on every render
  const filteredData = data.filter(item =>
    item.name.toLowerCase().includes(filterText.toLowerCase())
  );

  // With useMemo - only recalculates when dependencies change
  const filteredData = useMemo(() => {
    console.log('Filtering data...');
    return data.filter(item =>
      item.name.toLowerCase().includes(filterText.toLowerCase())
    );
  }, [data, filterText]); // Dependencies

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

// More complex example
function Dashboard({ users, startDate, endDate }) {
  const statistics = useMemo(() => {
    console.log('Calculating statistics...');

    const filtered = users.filter(user =>
      user.createdAt >= startDate && user.createdAt <= endDate
    );

    return {
      total: filtered.length,
      active: filtered.filter(u => u.active).length,
      revenue: filtered.reduce((sum, u) => sum + u.revenue, 0),
      averageAge: filtered.reduce((sum, u) => sum + u.age, 0) / filtered.length,
    };
  }, [users, startDate, endDate]);

  return <StatsDisplay stats={statistics} />;
}

useCallback for Function Memoization

useCallback memoizes function references to prevent unnecessary re-renders of child components.

import { useCallback, useState } from 'react';

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  // Without useCallback - new function on every render
  // Child re-renders even when count doesn't change
  const handleClick = () => {
    setCount(count + 1);
  };

  // With useCallback - same function reference when deps don't change
  const handleClick = useCallback(() => {
    setCount(prev => prev + 1);
  }, []); // No dependencies needed with updater function

  const handleTextChange = useCallback((newText) => {
    setText(newText);
  }, []);

  return (
    <div>
      <input value={text} onChange={(e) => handleTextChange(e.target.value)} />
      {/* MemoizedChild only re-renders when handleClick reference changes */}
      <MemoizedChild onClick={handleClick} />
    </div>
  );
}

const MemoizedChild = React.memo(({ onClick }) => {
  console.log('Child rendered');
  return <button onClick={onClick}>Click me</button>;
});

useCallback vs useMemo

useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). Use useCallback for functions, useMemo for values.

Code Splitting and Lazy Loading

Split your code into smaller chunks and load them on demand to reduce initial bundle size.

Route-Based Code Splitting

import { lazy, Suspense } from 'react';
import { BrowserRouter, 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'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>Loading...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/profile" element={<Profile />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Component-Based Code Splitting

import { lazy, Suspense, useState } from 'react';

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

function Dashboard() {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <h1>Dashboard</h1>
      <button onClick={() => setShowChart(true)}>
        Show Chart
      </button>

      {showChart && (
        <Suspense fallback={<div>Loading chart...</div>}>
          <HeavyChart />
        </Suspense>
      )}
    </div>
  );
}

Preloading Components

// Preload on hover for better UX
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function Navigation() {
  const handleMouseEnter = () => {
    // Preload before user clicks
    import('./HeavyComponent');
  };

  return (
    <button
      onMouseEnter={handleMouseEnter}
      onClick={() => navigate('/heavy')}
    >
      Go to Heavy Page
    </button>
  );
}

Virtual Scrolling for Large Lists

Rendering thousands of items is slow. Virtual scrolling only renders visible items.

// Using react-window
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>
  );
}

// Using react-virtuoso (more flexible)
import { Virtuoso } from 'react-virtuoso';

function VirtualizedList({ items }) {
  return (
    <Virtuoso
      style={{ height: '600px' }}
      data={items}
      itemContent={(index, item) => (
        <div>
          <h3>{item.name}</h3>
          <p>{item.description}</p>
        </div>
      )}
    />
  );
}

Debouncing and Throttling

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

function SearchComponent() {
  const [results, setResults] = useState([]);

  // Debounce search - only call after user stops typing
  const debouncedSearch = useCallback(
    debounce(async (query) => {
      const results = await fetchSearchResults(query);
      setResults(results);
    }, 300), // 300ms delay
    []
  );

  const handleChange = (e) => {
    debouncedSearch(e.target.value);
  };

  return (
    <div>
      <input onChange={handleChange} placeholder="Search..." />
      <SearchResults results={results} />
    </div>
  );
}

// Throttle scroll events
import { throttle } from 'lodash';

function InfiniteScroll() {
  const handleScroll = useCallback(
    throttle(() => {
      const scrollPercentage =
        (window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100;

      if (scrollPercentage > 80) {
        loadMoreData();
      }
    }, 200), // Max once per 200ms
    []
  );

  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, [handleScroll]);

  return <div>{/* content */}</div>;
}

Optimizing Context

Context changes cause all consumers to re-render. Split contexts and memoize values.

// Bad - Single context with multiple values
const AppContext = createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [settings, setSettings] = useState({});

  // All consumers re-render when ANY value changes
  return (
    <AppContext.Provider value={{ user, setUser, theme, setTheme, settings, setSettings }}>
      {children}
    </AppContext.Provider>
  );
}

// Good - Split contexts by concern
const UserContext = createContext();
const ThemeContext = createContext();
const SettingsContext = createContext();

// Memoize context values
function UserProvider({ children }) {
  const [user, setUser] = useState(null);

  const value = useMemo(() => ({ user, setUser }), [user]);

  return (
    <UserContext.Provider value={value}>
      {children}
    </UserContext.Provider>
  );
}

Image Optimization

// Lazy load images
function LazyImage({ src, alt }) {
  return (
    <img
      src={src}
      alt={alt}
      loading="lazy"
      decoding="async"
    />
  );
}

// Use Next.js Image component (automatic optimization)
import Image from 'next/image';

function OptimizedImage() {
  return (
    <Image
      src="/photo.jpg"
      alt="Photo"
      width={800}
      height={600}
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,..."
    />
  );
}

// Responsive images
function ResponsiveImage({ src, alt }) {
  return (
    <picture>
      <source media="(min-width: 1200px)" srcSet={`${src}?w=1200`} />
      <source media="(min-width: 768px)" srcSet={`${src}?w=768`} />
      <img src={`${src}?w=400`} alt={alt} loading="lazy" />
    </picture>
  );
}

Performance Measurement

React DevTools Profiler

Use the Profiler tab in React DevTools to identify slow components and unnecessary re-renders.

Performance API

import { Profiler } from 'react';

function onRenderCallback(
  id, // Component ID
  phase, // "mount" or "update"
  actualDuration, // Time spent rendering
  baseDuration, // Estimated time without memoization
  startTime,
  commitTime,
  interactions
) {
  console.log(`${id} (${phase}) took ${actualDuration}ms`);
}

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

Web Vitals

// Install web-vitals
npm install web-vitals

// Measure Core Web Vitals
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';

function sendToAnalytics(metric) {
  // Send to your analytics endpoint
  console.log(metric);
}

getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getFCP(sendToAnalytics);
getLCP(sendToAnalytics);
getTTFB(sendToAnalytics);

Bundle Size Optimization

# Analyze bundle size
npm install -D webpack-bundle-analyzer

# Add to package.json
"analyze": "ANALYZE=true next build"

# Run analysis
npm run analyze

Tree Shaking

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

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

// Even better - use modern alternatives
import { debounce } from 'lodash-es'; // ES modules for better tree shaking

Performance Checklist

  • Profile before optimizing
  • Use React.memo for expensive components
  • Memoize expensive calculations with useMemo
  • Memoize callbacks with useCallback
  • Implement code splitting for routes
  • Use virtual scrolling for large lists
  • Debounce user inputs
  • Optimize images (lazy loading, responsive)
  • Split context by concern
  • Minimize bundle size
  • Monitor Core Web Vitals

Conclusion

React performance optimization is about making strategic decisions based on real data. Don't optimize everything—focus on bottlenecks that actually impact user experience. Profile your application, identify slow components, apply appropriate optimization techniques, and measure the results.

Remember: premature optimization is the root of all evil. Build first, measure second, optimize third. Your users will thank you for a fast, responsive application that doesn't sacrifice maintainability for marginal performance gains.

Is your React app slow?

Yonda Solutions can review a React application, find the bottlenecks and fix them. Request a quote or email info@yondasolutions.com.