React Performance Optimization: Practical Techniques That Actually Work

Identify and fix React performance bottlenecks. Learn useMemo, useCallback, code splitting, virtualization, and profiling techniques with real-world examples.

Frontend Craft: React Performance Optimization: Practical Techniques That Actually Work

When to Optimize

Performance optimization without measurement is premature. Start by profiling your app to identify actual bottlenecks. Not every component needs optimization.

Optimize when:

  • Users report lag or stuttering
  • Profiler shows slow renders (>16ms)
  • Large lists or tables feel sluggish
  • Complex forms have input delay

Don’t optimize:

  • Before you have performance data
  • Components that render once
  • Simple presentational components

Profiling: Find the Real Problems

React DevTools Profiler

# Install React DevTools
# Chrome: https://chrome.google.com/webstore (search React Developer Tools)

How to profile:

  1. Open React DevTools → Profiler tab
  2. Click record
  3. Interact with your app
  4. Stop recording
  5. Analyze flame graph

What to look for:

  • Components with long render times (red/yellow)
  • Components that render unnecessarily
  • Render cascades (one change triggers many renders)

Browser Performance Tab

// Add performance marks
performance.mark('render-start');
// ... render logic
performance.mark('render-end');
performance.measure('render', 'render-start', 'render-end');

View in DevTools → Performance tab → User Timing section.

Preventing Unnecessary Renders

React.memo: Component Memoization

// ❌ Re-renders even when props haven't changed
function UserCard({ user }) {
  console.log('UserCard rendered');
  return <div>{user.name}</div>;
}

// ✅ Only re-renders when user changes
const UserCard = React.memo(function UserCard({ user }) {
  console.log('UserCard rendered');
  return <div>{user.name}</div>;
});

// ✅ With custom comparison
const UserCard = React.memo(
  function UserCard({ user }) {
    return <div>{user.name}</div>;
  },
  (prevProps, nextProps) => {
    // Return true to skip render
    return prevProps.user.id === nextProps.user.id &&
           prevProps.user.name === nextProps.user.name;
  }
);

When to use:

  • Expensive render logic
  • Pure presentational components
  • Components receiving complex props

When NOT to use:

  • Props change frequently
  • Component is already fast
  • Parent always re-renders anyway

useMemo: Memoize Expensive Calculations

function ProductList({ products, filters }) {
  // ❌ Filters on every render
  const filteredProducts = products.filter(
    p => p.category === filters.category
  );

  // ✅ Only recalculates when dependencies change
  const filteredProducts = useMemo(
    () => products.filter(p => p.category === filters.category),
    [products, filters.category]
  );

  return (
    <ul>
      {filteredProducts.map(p => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

Real-world example:

function DataTable({ data }) {
  const sortedData = useMemo(() => {
    console.log('Sorting data...');
    return [...data].sort((a, b) => 
      a.date.localeCompare(b.date)
    );
  }, [data]);

  const stats = useMemo(() => {
    console.log('Calculating stats...');
    return {
      total: sortedData.length,
      sum: sortedData.reduce((acc, item) => acc + item.value, 0),
      avg: sortedData.reduce((acc, item) => acc + item.value, 0) / sortedData.length,
    };
  }, [sortedData]);

  return (
    <div>
      <p>Total: {stats.total}, Avg: {stats.avg.toFixed(2)}</p>
      <ul>
        {sortedData.map(item => (
          <li key={item.id}>{item.name}: {item.value}</li>
        ))}
      </ul>
    </div>
  );
}

useCallback: Stabilize Function References

function TodoList({ todos }) {
  // ❌ New function on every render → child re-renders
  const handleToggle = (id) => {
    // toggle logic
  };

  // ✅ Stable function reference
  const handleToggle = useCallback((id) => {
    // toggle logic
  }, []);

  return (
    <ul>
      {todos.map(todo => (
        <TodoItem 
          key={todo.id} 
          todo={todo} 
          onToggle={handleToggle}
        />
      ))}
    </ul>
  );
}

const TodoItem = React.memo(function TodoItem({ todo, onToggle }) {
  return (
    <li onClick={() => onToggle(todo.id)}>
      {todo.text}
    </li>
  );
});

When useCallback matters:

// ✅ Matters: Passed to memoized child
const Child = React.memo(({ onClick }) => { /* ... */ });
const Parent = () => {
  const handleClick = useCallback(() => {}, []);
  return <Child onClick={handleClick} />;
};

// ❌ Doesn't matter: Child not memoized
const Child = ({ onClick }) => { /* ... */ };
const Parent = () => {
  const handleClick = useCallback(() => {}, []); // Unnecessary
  return <Child onClick={handleClick} />;
};

Code Splitting: Load Less, Faster

Route-Based Splitting

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

// ❌ All routes loaded upfront
import Home from './pages/Home';
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';

// ✅ Routes loaded on demand
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

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

Component-Based Splitting

// Heavy component loaded on interaction
const HeavyChart = lazy(() => import('./HeavyChart'));

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

  return (
    <div>
      <button onClick={() => setShowChart(true)}>
        Show Chart
      </button>
      {showChart && (
        <Suspense fallback={<ChartSkeleton />}>
          <HeavyChart data={chartData} />
        </Suspense>
      )}
    </div>
  );
}

Preloading on Hover

const Settings = lazy(() => import('./Settings'));

function Nav() {
  const handleMouseEnter = () => {
    // Preload Settings before click
    import('./Settings');
  };

  return (
    <nav>
      <Link 
        to="/settings" 
        onMouseEnter={handleMouseEnter}
      >
        Settings
      </Link>
    </nav>
  );
}

Virtualization: Handle Large Lists

Why Virtualization Matters

// ❌ Renders 10,000 DOM nodes
function BadList({ items }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

// Rendering 10,000 items:
// - Initial render: 2-3 seconds
// - Scroll: Janky, dropped frames
// - Memory: 50-100MB

React Window

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

function VirtualList({ 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>
  );
}

// Rendering 10,000 items:
// - Initial render: 50ms
// - Scroll: Smooth 60fps
// - Memory: 2-5MB

Variable Size Items

import { VariableSizeList } from 'react-window';

function VariableList({ items }) {
  const getItemSize = (index) => {
    // Dynamic height based on content
    return items[index].expanded ? 200 : 50;
  };

  const Row = ({ index, style }) => (
    <div style={style}>
      {/* Item content */}
    </div>
  );

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

State Management Performance

Context API Optimization

// ❌ Single context causes unnecessary renders
const AppContext = createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [settings, setSettings] = useState({});
  
  // Every state change re-renders all consumers
  return (
    <AppContext.Provider value={{ user, theme, settings, setUser, setTheme, setSettings }}>
      {children}
    </AppContext.Provider>
  );
}

// ✅ Split contexts by update frequency
const UserContext = createContext();
const ThemeContext = createContext();
const SettingsContext = createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [settings, setSettings] = useState({});
  
  return (
    <UserContext.Provider value={{ user, setUser }}>
      <ThemeContext.Provider value={{ theme, setTheme }}>
        <SettingsContext.Provider value={{ settings, setSettings }}>
          {children}
        </SettingsContext.Provider>
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

Memoize Context Values

function UserProvider({ children }) {
  const [user, setUser] = useState(null);
  
  // ❌ New object on every render
  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
  
  // ✅ Stable reference
  const value = useMemo(
    () => ({ user, setUser }),
    [user]
  );
  
  return (
    <UserContext.Provider value={value}>
      {children}
    </UserContext.Provider>
  );
}

Zustand: Better Performance Out of the Box

npm install zustand
import create from 'zustand';

// Only selected state triggers re-renders
const useStore = create((set) => ({
  user: null,
  theme: 'light',
  setUser: (user) => set({ user }),
  setTheme: (theme) => set({ theme }),
}));

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

// ✅ Only re-renders when theme changes
function ThemeToggle() {
  const theme = useStore((state) => state.theme);
  const setTheme = useStore((state) => state.setTheme);
  return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>Toggle</button>;
}

Debouncing and Throttling

Debounce Search Input

import { useState, useEffect } from 'react';

function SearchInput() {
  const [query, setQuery] = useState('');
  const [debouncedQuery, setDebouncedQuery] = useState('');

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedQuery(query);
    }, 300);

    return () => clearTimeout(timer);
  }, [query]);

  useEffect(() => {
    if (debouncedQuery) {
      // Expensive search operation
      searchAPI(debouncedQuery);
    }
  }, [debouncedQuery]);

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

Throttle Scroll Events

import { useEffect, useRef } from 'react';

function InfiniteScroll({ onLoadMore }) {
  const lastCall = useRef(0);

  useEffect(() => {
    const handleScroll = () => {
      const now = Date.now();
      // Throttle to max once per 200ms
      if (now - lastCall.current < 200) return;
      
      lastCall.current = now;
      
      const scrollHeight = document.documentElement.scrollHeight;
      const scrollTop = document.documentElement.scrollTop;
      const clientHeight = document.documentElement.clientHeight;
      
      if (scrollTop + clientHeight >= scrollHeight - 100) {
        onLoadMore();
      }
    };

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

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

Image Optimization

Lazy Loading Images

function ImageGallery({ images }) {
  return (
    <div>
      {images.map(img => (
        <img
          key={img.id}
          src={img.url}
          alt={img.alt}
          loading="lazy"
          decoding="async"
        />
      ))}
    </div>
  );
}

Responsive Images

function ResponsiveImage({ src, alt }) {
  return (
    <picture>
      <source
        srcSet={`${src}?w=400 400w, ${src}?w=800 800w, ${src}?w=1200 1200w`}
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
      />
      <img src={`${src}?w=800`} alt={alt} loading="lazy" />
    </picture>
  );
}

Bundle Size Optimization

Analyze Bundle

# Create React App
npm run build
npx webpack-bundle-analyzer build/static/js/*.js

# Vite
npm run build
npx vite-bundle-visualizer

Tree Shaking

// ❌ Imports entire library
import _ from 'lodash';
const result = _.debounce(fn, 300);

// ✅ 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 version

Dynamic Imports

// Load heavy library only when needed
function ChartComponent({ data }) {
  const [Chart, setChart] = useState(null);

  useEffect(() => {
    import('chart.js').then(module => {
      setChart(() => module.Chart);
    });
  }, []);

  if (!Chart) return <Skeleton />;
  
  return <Chart data={data} />;
}

Production Optimizations

Enable Production Build

// Automatic in production builds, but verify:
if (process.env.NODE_ENV === 'production') {
  // Production-only code
  console.log = () => {};
  console.warn = () => {};
}

Compression

// vite.config.js
import compression from 'vite-plugin-compression';

export default {
  plugins: [
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
    }),
  ],
};

Common Anti-Patterns

1. Premature Optimization

// ❌ Unnecessary memoization
const SimpleComponent = React.memo(function ({ text }) {
  return <p>{text}</p>;
});

// ✅ Just a regular component
function SimpleComponent({ text }) {
  return <p>{text}</p>;
}

2. Incorrect Dependencies

// ❌ Missing dependency
const memoizedValue = useMemo(
  () => expensiveCalculation(a, b),
  [a] // Missing b!
);

// ✅ All dependencies listed
const memoizedValue = useMemo(
  () => expensiveCalculation(a, b),
  [a, b]
);

3. Over-Memoization

// ❌ Memoizing everything
function Component() {
  const value1 = useMemo(() => prop1 + prop2, [prop1, prop2]);
  const value2 = useMemo(() => prop3 * 2, [prop3]);
  const handler = useCallback(() => {}, []);
  
  // More memoization overhead than render cost
}

// ✅ Memoize only expensive operations
function Component() {
  const value1 = prop1 + prop2; // Cheap, no memo needed
  const value2 = prop3 * 2; // Cheap, no memo needed
  
  const expensiveValue = useMemo(
    () => reallyExpensiveCalculation(data),
    [data]
  );
}

Performance Checklist

  • Profile before optimizing
  • Enable React.StrictMode in development
  • Use React DevTools Profiler
  • Implement code splitting for routes
  • Virtualize long lists
  • Memoize expensive calculations
  • Optimize images (lazy load, responsive)
  • Split Context to prevent unnecessary renders
  • Debounce user input
  • Use production build for deployment
  • Analyze bundle size
  • Enable compression (gzip/brotli)

Measuring Success

Before and After Metrics

  • Initial Load: Time to interactive
  • Runtime: 60fps during scrolling
  • Memory: No memory leaks
  • Bundle Size: < 200KB gzipped for initial load

Tools

  • Chrome DevTools Performance
  • React DevTools Profiler
  • Lighthouse
  • Web Vitals
  • webpack-bundle-analyzer

Resources