All posts
React & Frontend

React Performance Optimization: A Complete Guide to Building Lightning-Fast UIs

11 min readby imnb
ReactPerformanceOptimizationHooksMemoization
Share

Master React performance optimization with practical techniques including memoization, code splitting, lazy loading, and profiling. Learn when and how to optimize without premature optimization.

React is fast out of the box, but as your application grows, performance bottlenecks can emerge. The key is knowing when to optimize and which techniques to apply. In this comprehensive guide, we'll explore proven strategies to keep your React applications blazing fast.

Understanding React's Rendering Behavior

Before optimizing, you need to understand why React re-renders. A component re-renders when its state changes, its parent re-renders, or its context value changes. Unnecessary re-renders are the primary culprit of performance issues.

1. Memoization with React.memo

React.memo is a higher-order component that prevents re-renders if props haven't changed. Use it for components that render often with the same props.

javascript
import React, { memo } from 'react';

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

// With memo - only re-renders when props actually change
export default memo(ExpensiveComponent);

// Custom comparison function for complex props
export const MemoizedComponent = memo(
  ExpensiveComponent,
  (prevProps, nextProps) => {
    // Return true if props are equal (don't re-render)
    return (
      prevProps.data.length === nextProps.data.length &&
      prevProps.onAction === nextProps.onAction
    );
  }
);

⚠️ Caveat: React.memo does a shallow comparison. For objects and arrays, you need to ensure reference stability or provide a custom comparison function.

2. useCallback for Function Stability

Functions are recreated on every render. When passing callbacks to memoized child components, wrap them in useCallback to maintain reference equality.

javascript
import { useState, useCallback, memo } from 'react';

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [items, setItems] = useState([]);

  // ❌ Bad: Creates new function on every render
  const handleDelete = (id) => {
    setItems(prev => prev.filter(item => item.id !== id));
  };

  // ✅ Good: Function reference stays stable
  const handleDeleteOptimized = useCallback((id) => {
    setItems(prev => prev.filter(item => item.id !== id));
  }, []); // Empty deps - function never changes

  // ✅ With dependencies
  const handleUpdate = useCallback((id, newValue) => {
    setItems(prev => 
      prev.map(item => 
        item.id === id ? { ...item, value: newValue } : item
      )
    );
  }, []); // setItems is stable, no deps needed

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>
        Count: {count}
      </button>
      <ItemList items={items} onDelete={handleDeleteOptimized} />
    </>
  );
}

const ItemList = memo(({ items, onDelete }) => {
  console.log('ItemList rendered');
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>
          {item.name}
          <button onClick={() => onDelete(item.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
});

3. useMemo for Expensive Calculations

Use useMemo to cache expensive computations. Only recalculate when dependencies change.

javascript
import { useMemo } from 'react';

function DataTable({ data, filters }) {
  // ❌ Bad: Filters and sorts on every render
  const processedData = data
    .filter(item => item.status === filters.status)
    .sort((a, b) => b.priority - a.priority);

  // ✅ Good: Only recalculates when data or filters change
  const processedData = useMemo(() => {
    console.log('Processing data...');
    return data
      .filter(item => item.status === filters.status)
      .sort((a, b) => b.priority - a.priority);
  }, [data, filters.status]);

  // Example: Computing aggregate statistics
  const stats = useMemo(() => {
    return {
      total: processedData.length,
      completed: processedData.filter(d => d.status === 'done').length,
      avgPriority: processedData.reduce((sum, d) => sum + d.priority, 0) / processedData.length
    };
  }, [processedData]);

  return (
    <div>
      <div>Total: {stats.total}, Completed: {stats.completed}</div>
      <table>
        {processedData.map(item => (
          <tr key={item.id}>
            <td>{item.name}</td>
            <td>{item.priority}</td>
          </tr>
        ))}
      </table>
    </div>
  );
}

⚠️ Performance Tip: Don't use useMemo for cheap operations. The overhead of memoization might be worse than just recalculating. Profile first!

4. Code Splitting with React.lazy

Split your bundle into smaller chunks that load on demand. Essential for large applications.

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

// ❌ Bad: Loads entire Dashboard even if user never navigates there
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';
import Reports from './pages/Reports';

// ✅ Good: Each route loads on demand
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Reports = lazy(() => import('./pages/Reports'));

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

// Advanced: Preload on hover
function NavLink({ to, children }) {
  const prefetch = () => {
    // Webpack magic comment for prefetching
    import(/* webpackPrefetch: true */ './pages/Dashboard');
  };

  return (
    <Link to={to} onMouseEnter={prefetch}>
      {children}
    </Link>
  );
}

5. Virtualization for Long Lists

Rendering thousands of DOM nodes kills performance. Use virtualization to render only visible items.

javascript
import { FixedSizeList } from 'react-window';

// ❌ Bad: Renders 10,000 DOM nodes
function HugeList({ items }) {
  return (
    <div>
      {items.map(item => (
        <div key={item.id} style={{ height: 50 }}>
          {item.name}
        </div>
      ))}
    </div>
  );
}

// ✅ Good: Only renders visible items (~20 nodes)
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>
  );
}

// For variable heights
import { VariableSizeList } from 'react-window';

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

6. Profiling with React DevTools

Always profile before and after optimization. React DevTools Profiler shows you exactly what's slow.

javascript
import { Profiler } from 'react';

function App() {
  const onRenderCallback = (
    id, // "id" of the Profiler tree
    phase, // "mount" or "update"
    actualDuration, // Time spent rendering
    baseDuration, // Estimated time without memoization
    startTime,
    commitTime,
    interactions
  ) => {
    console.log(`${id} [${phase}] took ${actualDuration}ms`);
    
    // Send to analytics in production
    if (actualDuration > 16) { // Longer than one frame
      analytics.track('slow-render', {
        component: id,
        duration: actualDuration
      });
    }
  };

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

7. Avoid Inline Objects and Arrays

javascript
function Component({ userId }) {
  // ❌ Bad: Creates new object every render
  return (
    <UserCard 
      user={{ id: userId, active: true }}
      styles={{ padding: 20, margin: 10 }}
    />
  );
  
  // ✅ Good: Stable references
  const user = useMemo(() => ({ id: userId, active: true }), [userId]);
  const styles = { padding: 20, margin: 10 }; // Move outside component
  
  return <UserCard user={user} styles={styles} />;
}

Key Takeaways

  • Profile first, optimize second. Don't guess where the slow parts are.
  • React.memo prevents re-renders when props don't change
  • useCallback stabilizes function references between renders
  • useMemo caches expensive computation results
  • Code split routes and heavy components with React.lazy
  • Virtualize long lists with react-window or react-virtualized
  • Avoid inline objects/arrays in JSX - they break memoization
  • Context re-renders all consumers - split contexts when needed
  • Use the React DevTools Profiler to measure real impact

Remember: Premature optimization is the root of all evil. Start with clean, readable code. Profile your app. Then optimize the bottlenecks with these techniques. A well-architected React app handles thousands of components with ease.

Keep reading