Skip to main content

Performance Optimization

React is fast by default, but complex applications can slow down. This chapter covers techniques to identify bottlenecks and optimize your React apps.

When to Optimize

Premature optimization is the root of all evil. Always measure first! Only optimize when you:
  1. Have actual performance issues users notice
  2. Have profiled and identified the bottleneck
  3. Can measure the improvement
Most React apps don’t need heavy optimization.

Understanding Re-renders

Every time state or props change, React re-renders components. Understanding this is key to optimization.
┌─────────────────────────────────────────────────────────────┐
│                  Component Re-render Flow                   │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  State/Props Change                                         │
│         │                                                   │
│         ▼                                                   │
│  Component function runs again                              │
│         │                                                   │
│         ▼                                                   │
│  New JSX created                                            │
│         │                                                   │
│         ▼                                                   │
│  React diffs Virtual DOM                                    │
│         │                                                   │
│         ▼                                                   │
│  Only actual changes update real DOM                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

What Causes Re-renders?

  1. Component’s state changes (useState, useReducer)
  2. Parent component re-renders
  3. Context value changes
  4. Custom hook state changes

React DevTools Profiler

The Profiler helps identify what’s rendering and why.

Using the Profiler

  1. Install React DevTools browser extension
  2. Open DevTools → Profiler tab
  3. Click “Record” and interact with your app
  4. Analyze the flame graph

What to Look For

  • Frequent re-renders of the same component
  • Slow components (long render times)
  • Cascade re-renders (parent update causing all children to update)

React.memo - Prevent Unnecessary Re-renders

React.memo is a higher-order component that memoizes functional components.

Basic Usage

// Without memo - re-renders whenever parent renders
function 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 `items` changes
const ExpensiveList = React.memo(function ExpensiveList({ items }) {
  console.log('ExpensiveList rendered');
  return (
    <ul>
      {items.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
});

Custom Comparison

const UserCard = React.memo(
  function UserCard({ user, onSelect }) {
    return (
      <div onClick={() => onSelect(user.id)}>
        <h3>{user.name}</h3>
        <p>{user.email}</p>
      </div>
    );
  },
  // Custom comparison function
  (prevProps, nextProps) => {
    return prevProps.user.id === nextProps.user.id &&
           prevProps.user.name === nextProps.user.name;
  }
);
React.memo does a shallow comparison by default. If you pass new object/array references every render, memoization won’t work!
// ❌ Bad - new array on every render
<MemoizedComponent items={data.filter(x => x.active)} />

// ✅ Good - memoize the filtered data
const activeItems = useMemo(() => data.filter(x => x.active), [data]);
<MemoizedComponent items={activeItems} />

useMemo - Memoize Expensive Calculations

useMemo caches the result of expensive computations.
function ProductList({ products, filter }) {
  // ❌ Without useMemo - runs on every render
  const filteredProducts = products
    .filter(p => p.category === filter)
    .sort((a, b) => a.price - b.price);

  // ✅ With useMemo - only recalculates when dependencies change
  const filteredProducts = useMemo(() => {
    console.log('Filtering and sorting...');
    return products
      .filter(p => p.category === filter)
      .sort((a, b) => a.price - b.price);
  }, [products, filter]);

  return (/* render list */);
}

When to Use useMemo

Use useMemoDon’t Use useMemo
Expensive calculationsSimple math/string operations
Large array operationsSmall arrays (fewer than 100 items)
Object creation for memoized childrenObjects used once
Derived data from props/stateValues passed to native elements

useCallback - Memoize Functions

useCallback caches function references to prevent unnecessary re-renders of child components.
function Parent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  // ❌ New function created every render
  const handleClick = () => {
    console.log('Clicked!');
  };

  // ✅ Same function reference unless dependencies change
  const handleClick = useCallback(() => {
    console.log('Clicked!');
  }, []);

  // With dependencies
  const handleSubmit = useCallback(() => {
    submitForm(text);
  }, [text]);

  return (
    <div>
      <input value={text} onChange={e => setText(e.target.value)} />
      <Counter count={count} />
      <MemoizedButton onClick={handleClick} />
    </div>
  );
}

const MemoizedButton = React.memo(function Button({ onClick }) {
  console.log('Button rendered');
  return <button onClick={onClick}>Click me</button>;
});
useCallback + React.memo = Optimization Power CombouseCallback alone doesn’t prevent re-renders. It only helps when:
  1. Passed to a React.memo-wrapped child component
  2. Used as a dependency in other hooks

Code Splitting with React.lazy

Split your bundle so users only download what they need.
import { lazy, Suspense } from 'react';

// Lazy load components
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/analytics" element={<Analytics />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

Named Exports with Lazy

// For named exports, create an intermediate module
// utils/lazyImport.js
export function lazyImport(factory, name) {
  return lazy(() => 
    factory().then(module => ({ default: module[name] }))
  );
}

// Usage
const Dashboard = lazyImport(
  () => import('./pages/Dashboard'),
  'Dashboard'
);

Virtualization for Long Lists

Only render visible items in long lists.

Using react-window

npm install react-window
import { FixedSizeList } from 'react-window';

function VirtualizedList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style} className="list-item">
      {items[index].name}
    </div>
  );

  return (
    <FixedSizeList
      height={400}
      width="100%"
      itemCount={items.length}
      itemSize={50}
    >
      {Row}
    </FixedSizeList>
  );
}

Variable Size List

import { VariableSizeList } from 'react-window';

function VariableList({ items }) {
  const getItemSize = (index) => {
    return items[index].type === 'header' ? 80 : 50;
  };

  const Row = ({ index, style }) => (
    <div style={style}>
      {items[index].content}
    </div>
  );

  return (
    <VariableSizeList
      height={400}
      width="100%"
      itemCount={items.length}
      itemSize={getItemSize}
    >
      {Row}
    </VariableSizeList>
  );
}

Debouncing and Throttling

Limit how often functions are called.

Debouncing (Wait for pause)

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// Usage
function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      searchAPI(debouncedQuery);
    }
  }, [debouncedQuery]);

  return (
    <input 
      value={query} 
      onChange={e => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}

Throttling (Limit frequency)

function useThrottle(value, limit) {
  const [throttledValue, setThrottledValue] = useState(value);
  const lastRan = useRef(Date.now());

  useEffect(() => {
    const handler = setTimeout(() => {
      if (Date.now() - lastRan.current >= limit) {
        setThrottledValue(value);
        lastRan.current = Date.now();
      }
    }, limit - (Date.now() - lastRan.current));

    return () => clearTimeout(handler);
  }, [value, limit]);

  return throttledValue;
}

Image Optimization

Lazy Loading Images

function LazyImage({ src, alt, ...props }) {
  return (
    <img 
      src={src} 
      alt={alt} 
      loading="lazy"  // Native lazy loading
      {...props}
    />
  );
}

Progressive Loading with Blur

function ProgressiveImage({ lowQualitySrc, highQualitySrc, alt }) {
  const [loaded, setLoaded] = useState(false);

  return (
    <div className="progressive-image">
      <img 
        src={lowQualitySrc}
        alt={alt}
        className={`low-quality ${loaded ? 'hidden' : ''}`}
        style={{ filter: 'blur(10px)' }}
      />
      <img 
        src={highQualitySrc}
        alt={alt}
        className={`high-quality ${loaded ? '' : 'hidden'}`}
        onLoad={() => setLoaded(true)}
      />
    </div>
  );
}

Web Vitals

Key metrics for user experience:
MetricDescriptionTarget
LCP (Largest Contentful Paint)Main content visible< 2.5s
FID (First Input Delay)Time to interactive< 100ms
CLS (Cumulative Layout Shift)Visual stability< 0.1
TTFB (Time to First Byte)Server response time< 600ms

Measuring Web Vitals

import { getCLS, getFID, getLCP } from 'web-vitals';

getCLS(console.log);
getFID(console.log);
getLCP(console.log);

// Or send to analytics
function sendToAnalytics({ name, delta, id }) {
  analytics.send({
    metric: name,
    value: delta,
    id
  });
}

getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getLCP(sendToAnalytics);

Production Build Optimization

Vite Production Build

npm run build
npm run preview  # Preview production build locally

Analyze Bundle Size

# Install bundle analyzer
npm install -D rollup-plugin-visualizer

# vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),
    visualizer({ open: true })
  ]
});

Environment-Specific Code

// Remove development-only code in production
if (import.meta.env.DEV) {
  console.log('Debug info:', data);
}

Deployment

npm install -g vercel
vercel
Or connect your GitHub repo at vercel.com.

Netlify

Create netlify.toml:
[build]
  command = "npm run build"
  publish = "dist"

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

Docker

# Build stage
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

🎯 Practice Exercises

// Before: Slow component
function ProductList({ products, onSelect }) {
  const [filter, setFilter] = useState('');

  // Recalculates on every keystroke
  const filtered = products.filter(p => 
    p.name.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <div>
      <input 
        value={filter} 
        onChange={e => setFilter(e.target.value)} 
      />
      {filtered.map(product => (
        <ProductCard 
          key={product.id}
          product={product}
          onSelect={() => onSelect(product)}
        />
      ))}
    </div>
  );
}

// After: Optimized
const ProductCard = React.memo(function ProductCard({ product, onSelect }) {
  return (
    <div onClick={onSelect}>
      <h3>{product.name}</h3>
      <p>${product.price}</p>
    </div>
  );
});

function ProductList({ products, onSelect }) {
  const [filter, setFilter] = useState('');
  const debouncedFilter = useDebounce(filter, 300);

  const filtered = useMemo(() => 
    products.filter(p => 
      p.name.toLowerCase().includes(debouncedFilter.toLowerCase())
    ),
    [products, debouncedFilter]
  );

  const handleSelect = useCallback((product) => {
    onSelect(product);
  }, [onSelect]);

  return (
    <div>
      <input 
        value={filter} 
        onChange={e => setFilter(e.target.value)} 
      />
      {filtered.map(product => (
        <ProductCard 
          key={product.id}
          product={product}
          onSelect={() => handleSelect(product)}
        />
      ))}
    </div>
  );
}

Summary

TechniqueUse Case
React.memoPrevent child re-renders when props unchanged
useMemoCache expensive calculations
useCallbackCache function references
React.lazyCode split by route/component
VirtualizationLong lists (100+ items)
DebouncingSearch inputs, resize handlers
Lazy loadingImages below the fold
Bundle analysisIdentify large dependencies
Web VitalsMeasure real user experience

Next Steps

In the next chapter, you’ll learn about Authentication & Protected Routes — securing your React applications!