ESC
Type to search guides, tutorials, and reference documentation.
Verified by Garnet Grid

React Performance Optimization: From Slow Renders to 60fps

Optimize React application performance with techniques that eliminate unnecessary re-renders, reduce bundle size, and deliver a smooth 60fps user experience. Covers React.memo, useMemo, useCallback, code splitting, virtualization, profiling, and the mental model for understanding when and why React re-renders.

React re-renders components when state changes. This is not a bug — it is the core mechanism. The problem occurs when a single state change triggers re-renders across hundreds of components that did not need to update. A fast app becomes sluggish. Typing lags. Scrolling stutters.

Most React performance problems stem from three causes: unnecessary re-renders, large bundle sizes, and expensive computations on the main thread. This guide covers practical techniques for fixing each one.


The Re-Render Mental Model

React re-renders a component when:

  1. Its state changes (useState, useReducer)
  2. Its parent re-renders (props may or may not change)
  3. A context it consumes changes (useContext)

React does NOT re-render because:
  ❌ A variable outside state changed
  ❌ A ref changed (useRef)
  ❌ An event handler was called (without state change)

The Cascade Problem

App (state changes)
├── Header (re-renders — child of App) ❌ unnecessary
│   └── Logo (re-renders — child of Header) ❌ unnecessary
├── Sidebar (re-renders — child of App) ❌ unnecessary
│   └── NavItems (re-renders) ❌ unnecessary
├── MainContent (re-renders — child of App) ✅ needed
│   └── UserProfile (re-renders) ✅ needed
└── Footer (re-renders — child of App) ❌ unnecessary

When App re-renders, ALL children re-render by default.
Only MainContent actually needed the update.

React.memo: Prevent Unnecessary Child Re-Renders

// ❌ Without memo: Header re-renders every time App state changes
function Header({ title }: { title: string }) {
  console.log('Header rendered'); // Fires on every parent re-render
  return <header><h1>{title}</h1></header>;
}

// ✅ With memo: Header only re-renders when 'title' prop actually changes
const Header = React.memo(function Header({ title }: { title: string }) {
  console.log('Header rendered'); // Only fires when title changes
  return <header><h1>{title}</h1></header>;
});

When to Use React.memo

Use MemoSkip Memo
Component renders frequentlyComponent rarely re-renders
Props rarely changeProps change on every render
Component is expensive to renderComponent is cheap (simple HTML)
Component is deep in the treeComponent is the root or near root

useMemo and useCallback: Stabilize References

// ❌ Problem: new object created every render, breaks memo
function App() {
  const [count, setCount] = useState(0);

  // This creates a NEW object every render
  const config = { theme: 'dark', locale: 'en' };

  // This creates a NEW function every render
  const handleClick = () => console.log('clicked');

  return (
    <>
      <ExpensiveList config={config} onClick={handleClick} />
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
    </>
  );
}

// ✅ Fixed: stable references prevent unnecessary re-renders
function App() {
  const [count, setCount] = useState(0);

  const config = useMemo(() => ({ theme: 'dark', locale: 'en' }), []);
  const handleClick = useCallback(() => console.log('clicked'), []);

  return (
    <>
      <ExpensiveList config={config} onClick={handleClick} />
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
    </>
  );
}

Code Splitting: Smaller Initial Bundles

import { lazy, Suspense } from 'react';

// ❌ Eager import: entire admin panel loaded on first page
import AdminDashboard from './AdminDashboard';

// ✅ Lazy import: admin panel loaded only when needed
const AdminDashboard = lazy(() => import('./AdminDashboard'));

function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route
        path="/admin"
        element={
          <Suspense fallback={<LoadingSpinner />}>
            <AdminDashboard />
          </Suspense>
        }
      />
    </Routes>
  );
}

Bundle Analysis

# Analyze what's in your bundle
npx source-map-explorer build/static/js/*.js

# Or use webpack-bundle-analyzer
# Add to webpack config: new BundleAnalyzerPlugin()
TargetGoal
Initial JS bundle< 200KB gzipped
Time to Interactive< 3 seconds on 3G
Largest Contentful Paint< 2.5 seconds
Cumulative Layout Shift< 0.1

Virtualization: Rendering Only What’s Visible

import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualizedList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,       // 10,000 items
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,    // Each row ~50px
    overscan: 5,               // Render 5 extra rows above/below viewport
  });

  return (
    <div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map(virtualRow => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: 0,
              transform: `translateY(${virtualRow.start}px)`,
              height: `${virtualRow.size}px`,
              width: '100%',
            }}
          >
            <ListItem item={items[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}
// Renders ~15 rows instead of 10,000 → massive performance gain

Profiling with React DevTools

Steps to profile:
  1. Install React DevTools browser extension
  2. Open Profiler tab
  3. Click "Record"
  4. Perform the slow interaction
  5. Click "Stop"
  6. Review the flame graph:
     - Yellow/red bars = slow renders
     - Gray bars = did not re-render
     - Click any bar to see why it rendered

  Common findings:
  - "Props changed" → stabilize with useMemo/useCallback
  - "Parent rendered" → wrap child in React.memo
  - "Context changed" → split context into multiple providers

Anti-Patterns

Anti-PatternProblemFix
Inline objects in JSXstyle={{ color: 'red' }} creates new object each renderExtract to constant or useMemo
Context for everythingOne big context → all consumers re-render on any changeSplit context by update frequency
State in the wrong placeState at root causes entire tree to re-renderLift state down, not up
Premature optimizationReact.memo on everything adds overheadProfile first, optimize second

Implementation Checklist

  • Profile your app with React DevTools Profiler before optimizing
  • Wrap expensive child components in React.memo
  • Stabilize object/function props with useMemo and useCallback
  • Code-split routes and heavy components with React.lazy
  • Virtualize lists with 100+ items using @tanstack/react-virtual
  • Keep initial JS bundle under 200KB gzipped
  • Split large contexts into separate providers by update frequency
  • Move state as close to its consumers as possible
  • Analyze bundle size with source-map-explorer and eliminate unused dependencies
  • Monitor Core Web Vitals (LCP, CLS, FID/INP) in production
Jakub Dimitri Rezayev
Jakub Dimitri Rezayev
Founder & Chief Architect • Garnet Grid Consulting

Jakub holds an M.S. in Customer Intelligence & Analytics and a B.S. in Finance & Computer Science from Pace University. With deep expertise spanning D365 F&O, Azure, Power BI, and AI/ML systems, he architects enterprise solutions that bridge legacy systems and modern technology — and has led multi-million dollar ERP implementations for Fortune 500 supply chains.

View Full Profile →