Back to Blog
9 min readTutorial

React Performance Optimization: From Slow to Fast

Transform your slow React application into a blazing-fast experience with proven performance optimization techniques and patterns

React Performance Optimization: From Slow to Fast

A slow React application frustrates users and hurts your business. But performance optimization doesn't have to be mysterious. Let's transform your sluggish app into a lightning-fast experience using proven techniques.

Step 1: Measure First, Optimize Second

Never optimize blindly. Use React DevTools Profiler to identify bottlenecks.

Using React DevTools Profiler

// Wrap components to profile
import { Profiler } from 'react';

function onRenderCallback(
  id, // component name
  phase, // "mount" or "update"
  actualDuration, // time spent rendering
  baseDuration, // estimated time without memoization
  startTime,
  commitTime,
  interactions // Set of interactions belonging to this update
) {
  console.log(`${id}'s ${phase} phase:`);
  console.log(`Actual duration: ${actualDuration}ms`);
  console.log(`Base duration: ${baseDuration}ms`);
}

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

Chrome DevTools Performance Tab

  1. Open Chrome DevTools → Performance tab
  2. Click Record
  3. Interact with your app
  4. Stop recording
  5. Look for long tasks (yellow/red blocks)

Step 2: Prevent Unnecessary Re-renders

The #1 cause of slow React apps is unnecessary re-renders.

Technique 1: React.memo for Component Memoization

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

// ✅ Good: 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 if you want to skip re-render
    return prevProps.user.id === nextProps.user.id;
  }
);

Technique 2: useMemo for Expensive Computations

// ❌ Bad: Sorts on every render
function ProductList({ products, filter }) {
  const sortedProducts = products
    .filter(p => p.category === filter)
    .sort((a, b) => b.rating - a.rating);

  return sortedProducts.map(p => <ProductCard key={p.id} product={p} />);
}

// ✅ Good: Only sorts when products or filter change
function ProductList({ products, filter }) {
  const sortedProducts = useMemo(() => {
    return products
      .filter(p => p.category === filter)
      .sort((a, b) => b.rating - a.rating);
  }, [products, filter]);

  return sortedProducts.map(p => <ProductCard key={p.id} product={p} />);
}

Technique 3: useCallback for Stable Function References

// ❌ Bad: Creates new function on every render
function TodoList({ todos }) {
  const handleToggle = (id) => {
    toggleTodo(id);
  };

  return todos.map(todo => (
    <TodoItem
      key={todo.id}
      todo={todo}
      onToggle={handleToggle} // New function reference every render!
    />
  ));
}

// ✅ Good: Stable function reference
function TodoList({ todos }) {
  const handleToggle = useCallback((id) => {
    toggleTodo(id);
  }, []); // No dependencies, function never changes

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

Step 3: Optimize List Rendering

Large lists are performance killers. Here's how to fix them.

Technique 1: Virtualization with react-window

Only render items visible in the viewport.

import { FixedSizeList } from 'react-window';

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

// ✅ Good: Only renders visible items
function HugeList({ items }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={50}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>
          {items[index].name}
        </div>
      )}
    </FixedSizeList>
  );
}

Technique 2: Pagination

Break large datasets into pages.

function PaginatedList({ items }) {
  const [page, setPage] = useState(1);
  const itemsPerPage = 20;

  const paginatedItems = useMemo(() => {
    const start = (page - 1) * itemsPerPage;
    return items.slice(start, start + itemsPerPage);
  }, [items, page]);

  return (
    <div>
      {paginatedItems.map(item => (
        <ItemCard key={item.id} item={item} />
      ))}
      <Pagination
        currentPage={page}
        totalPages={Math.ceil(items.length / itemsPerPage)}
        onPageChange={setPage}
      />
    </div>
  );
}

Technique 3: Infinite Scroll

Load more items as user scrolls.

function InfiniteScrollList() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);

  const loadMore = async () => {
    const newItems = await fetchItems(page);
    if (newItems.length === 0) {
      setHasMore(false);
      return;
    }
    setItems(prev => [...prev, ...newItems]);
    setPage(prev => prev + 1);
  };

  return (
    <div>
      {items.map(item => (
        <ItemCard key={item.id} item={item} />
      ))}
      {hasMore && (
        <div ref={(node) => {
          if (node) {
            const observer = new IntersectionObserver((entries) => {
              if (entries[0].isIntersecting) {
                loadMore();
              }
            });
            observer.observe(node);
            return () => observer.disconnect();
          }
        }}>
          Loading more...
        </div>
      )}
    </div>
  );
}

Step 4: Code Splitting and Lazy Loading

Don't load everything upfront. Split your bundle and load on demand.

Lazy Load Components

import { lazy, Suspense } from 'react';

// ❌ Bad: Loads entire app upfront
import Dashboard from './Dashboard';
import Settings from './Settings';
import Reports from './Reports';

// ✅ Good: Loads components when needed
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
const Reports = lazy(() => import('./Reports'));

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

Lazy Load Heavy Libraries

// ❌ Bad: Loads chart library even if not used
import Chart from 'chart.js';

function Dashboard() {
  return <div>Dashboard content</div>;
}

// ✅ Good: Only loads when chart is rendered
function Dashboard() {
  const [showChart, setShowChart] = useState(false);

  const loadChart = async () => {
    const Chart = await import('chart.js');
    // Use Chart library
  };

  return (
    <div>
      <button onClick={() => setShowChart(true)}>Show Chart</button>
      {showChart && <Suspense fallback="Loading...">
        <ChartComponent onLoad={loadChart} />
      </Suspense>}
    </div>
  );
}

Step 5: Optimize State Updates

State updates trigger re-renders. Optimize how and where you update state.

Technique 1: Collocate State

Keep state as close as possible to where it's used.

// ❌ Bad: State at top level affects entire app
function App() {
  const [modalOpen, setModalOpen] = useState(false);
  const [formData, setFormData] = useState({});

  return (
    <div>
      <Header />
      <Sidebar />
      <Content />
      {modalOpen && <Modal data={formData} />}
    </div>
  );
}

// ✅ Good: State only where needed
function App() {
  return (
    <div>
      <Header />
      <Sidebar />
      <Content />
      <ModalSection />
    </div>
  );
}

function ModalSection() {
  const [modalOpen, setModalOpen] = useState(false);
  const [formData, setFormData] = useState({});

  return modalOpen ? <Modal data={formData} /> : null;
}

Technique 2: Split Large State Objects

// ❌ Bad: Entire form re-renders on any field change
function LargeForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    address: '',
    // ... 20 more fields
  });

  return (
    <form>
      <input
        value={formData.name}
        onChange={e => setFormData({...formData, name: e.target.value})}
      />
      {/* Every input causes entire form to re-render */}
    </form>
  );
}

// ✅ Good: Split into separate components
function LargeForm() {
  return (
    <form>
      <NameField />
      <EmailField />
      <AddressField />
    </form>
  );
}

function NameField() {
  const [name, setName] = useState('');
  return <input value={name} onChange={e => setName(e.target.value)} />;
}

Technique 3: Debounce Expensive Operations

import { useMemo, useState, useEffect } from 'react';

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

  // Debounce query
  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedQuery(query);
    }, 500);

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

  // Only search when debounced query changes
  const results = useMemo(() => {
    return expensiveSearch(debouncedQuery);
  }, [debouncedQuery]);

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

Step 6: Optimize Images and Assets

Large images slow down your app. Optimize them!

Technique 1: Lazy Load Images

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

Technique 2: Use Next.js Image Component

import Image from 'next/image';

// ❌ Bad: Unoptimized image
<img src="/large-image.jpg" alt="Hero" />

// ✅ Good: Optimized, responsive, lazy-loaded
<Image
  src="/large-image.jpg"
  alt="Hero"
  width={1200}
  height={600}
  priority // For above-the-fold images
  placeholder="blur"
/>

Technique 3: Progressive Image Loading

function ProgressiveImage({ src, placeholder }) {
  const [imageSrc, setImageSrc] = useState(placeholder);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const img = new Image();
    img.src = src;
    img.onload = () => {
      setImageSrc(src);
      setLoading(false);
    };
  }, [src]);

  return (
    <div className={loading ? 'loading' : ''}>
      <img src={imageSrc} alt="" />
    </div>
  );
}

Step 7: Web Workers for Heavy Computations

Move CPU-intensive work off the main thread.

// worker.js
self.addEventListener('message', (e) => {
  const { data } = e;

  // Heavy computation
  const result = processLargeDataset(data);

  self.postMessage(result);
});

// Component
function DataProcessor() {
  const [result, setResult] = useState(null);

  useEffect(() => {
    const worker = new Worker(new URL('./worker.js', import.meta.url));

    worker.postMessage(largeDataset);

    worker.onmessage = (e) => {
      setResult(e.data);
    };

    return () => worker.terminate();
  }, []);

  return <div>{result}</div>;
}

Step 8: Use Production Builds

Always use production builds for performance testing.

# Development build (slow, includes warnings)
npm run dev

# Production build (fast, optimized)
npm run build
npm run start

Enable React Production Mode

// Vite config
export default defineConfig({
  plugins: [react()],
  build: {
    minify: 'terser',
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['react', 'react-dom'],
        },
      },
    },
  },
});

Performance Checklist

  • Measured performance with React DevTools Profiler
  • Wrapped expensive components with React.memo
  • Used useMemo for expensive computations
  • Used useCallback for stable function references
  • Virtualized long lists with react-window
  • Implemented code splitting with lazy()
  • Lazy loaded heavy libraries
  • Collocated state close to usage
  • Debounced expensive operations
  • Optimized images (lazy loading, proper formats)
  • Moved heavy computations to Web Workers
  • Using production builds
  • Monitored bundle size
  • Implemented proper caching strategies

Common Pitfalls

1. Over-optimization

// ❌ Bad: Unnecessary optimization
function SimpleCounter() {
  const [count, setCount] = useState(0);

  // Useless memo - this is already fast
  const displayValue = useMemo(() => count, [count]);

  return <div>{displayValue}</div>;
}

2. Incorrect Dependencies

// ❌ Bad: Missing dependencies
const memoizedValue = useMemo(() => {
  return expensiveCalc(a, b);
}, [a]); // Missing 'b'!

// ✅ Good: All dependencies included
const memoizedValue = useMemo(() => {
  return expensiveCalc(a, b);
}, [a, b]);

3. Premature Optimization

Focus on the biggest bottlenecks first. A 50% improvement on a component that takes 2ms is meaningless, but a 10% improvement on a component that takes 500ms is huge!

Conclusion

React performance optimization is about:

  1. Measure first - Use React DevTools Profiler
  2. Prevent re-renders - memo, useMemo, useCallback
  3. Optimize lists - Virtualization, pagination
  4. Split code - Lazy loading, dynamic imports
  5. Optimize state - Collocate, split, debounce
  6. Optimize assets - Lazy load images, use modern formats
  7. Production builds - Always test with production builds

Start with the biggest bottlenecks and work your way down. Your users will thank you with better engagement and lower bounce rates!

👨‍💻

Jordan Patel

Web Developer & Technology Enthusiast