DEV Community

Cover image for React Performance Optimization: Advanced Techniques That Actually Work
Muhammad Arslan
Muhammad Arslan

Posted on • Originally published at muhammadarslan.codes

React Performance Optimization: Advanced Techniques That Actually Work

Your React app works. But is it fast?

In my experience building React applications for enterprise clients, I've seen apps that feel snappy with 100 users suddenly crawl to a halt with 10,000. The difference isn't the server — it's how the frontend handles rendering.

Here's the comprehensive guide to making your React apps blazingly fast, using techniques I apply on every production project.


Understanding the Cost of Rendering

Before optimizing, you need to understand why React re-renders.

React re-renders a component when:

  1. Its state changes
  2. Its props change
  3. Its parent re-renders (even if props didn't change!)

That third point is the silent killer. A single state change at the top of your component tree can cascade into hundreds of unnecessary re-renders.

How to Measure

Before optimizing blindly, profile first:

# Install React DevTools browser extension
# Then in your app:
# 1. Open DevTools → Profiler tab
# 2. Click Record
# 3. Interact with your app
# 4. Stop Recording
# 5. Look for components with high "render time"
Enter fullscreen mode Exit fullscreen mode

You can also add this to spot unnecessary re-renders during development:

// hooks/useWhyDidYouRender.ts
import { useRef, useEffect } from 'react';

export function useWhyDidYouRender(name: string, props: Record<string, any>) {
  const prev = useRef(props);

  useEffect(() => {
    const changes: Record<string, { from: any; to: any }> = {};

    Object.entries(props).forEach(([key, value]) => {
      if (prev.current[key] !== value) {
        changes[key] = { from: prev.current[key], to: value };
      }
    });

    if (Object.keys(changes).length > 0) {
      console.log(`[WhyRender] ${name}:`, changes);
    }

    prev.current = props;
  });
}
Enter fullscreen mode Exit fullscreen mode

1. Memoization — useMemo and useCallback

These hooks prevent expensive calculations or function recreations on every render.

useMemo — Memoize Computed Values

// ❌ BAD — recalculates on every render
const FilteredProducts = ({ products, filter }) => {
  const filtered = products.filter(p => p.category === filter);
  const sorted = filtered.sort((a, b) => b.price - a.price);

  return <ProductList items={sorted} />;
};

// ✅ GOOD — only recalculates when dependencies change
const FilteredProducts = ({ products, filter }) => {
  const sorted = useMemo(() => {
    const filtered = products.filter(p => p.category === filter);
    return filtered.sort((a, b) => b.price - a.price);
  }, [products, filter]);

  return <ProductList items={sorted} />;
};
Enter fullscreen mode Exit fullscreen mode

useCallback — Memoize Functions

// ❌ BAD — new function reference every render → child always re-renders
const Parent = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    console.log('clicked');
  };

  return <ExpensiveChild onClick={handleClick} />;
};

// ✅ GOOD — stable function reference
const Parent = () => {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  return <ExpensiveChild onClick={handleClick} />;
};
Enter fullscreen mode Exit fullscreen mode

When NOT to Use Them

Don't wrap everything in useMemo/useCallback. Memoization itself has a cost (memory + comparison). Only use them when:

  • The computation is genuinely expensive (filtering/sorting large arrays)
  • The result is passed to a memoized child (React.memo)
  • You're preventing expensive child re-renders

2. React.memo — Prevent Unnecessary Re-renders

Wrap components that receive stable props but re-render because their parent did:

// This component only re-renders when `items` or `onSelect` actually change
const ProductList = React.memo(({ items, onSelect }) => {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => onSelect(item.id)}>
          {item.name}  ${item.price}
        </li>
      ))}
    </ul>
  );
});
Enter fullscreen mode Exit fullscreen mode

Custom Comparison

For complex props, provide a custom comparator:

const Chart = React.memo(({ data, config }) => {
  // Expensive chart rendering
  return <canvas ref={chartRef} />;
}, (prevProps, nextProps) => {
  // Only re-render if data length changes or config updates
  return (
    prevProps.data.length === nextProps.data.length &&
    prevProps.config.type === nextProps.config.type
  );
});
Enter fullscreen mode Exit fullscreen mode

3. Virtualization — Render Only What's Visible

If you're rendering a list of 1,000+ items, the browser is creating 1,000+ DOM nodes. Virtualization renders only the ~20 visible items and recycles DOM nodes as the user scrolls.

Using react-window

npm install react-window
Enter fullscreen mode Exit fullscreen mode
import { FixedSizeList } from 'react-window';

const BigList = ({ items }) => {
  const Row = ({ index, style }) => (
    <div style={style} className="flex items-center p-4 border-b">
      <span>{items[index].name}</span>
      <span className="ml-auto">${items[index].price}</span>
    </div>
  );

  return (
    <FixedSizeList
      height={600}        // Container height
      itemCount={items.length}
      itemSize={60}        // Row height
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
};
Enter fullscreen mode Exit fullscreen mode

Result: Instead of 10,000 DOM nodes, you have ~15. Scroll performance goes from choppy to silky smooth.

For variable-height rows, use VariableSizeList. For grids, use FixedSizeGrid.


4. Code Splitting — Load Only What's Needed

Don't make users download your entire app upfront. Split your bundle by routes and heavy components:

import { lazy, Suspense } from 'react';

// These components are loaded only when the route is visited
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));

const App = () => (
  <Suspense fallback={<LoadingSpinner />}>
    <Routes>
      <Route path="/dashboard" element={<Dashboard />} />
      <Route path="/analytics" element={<Analytics />} />
      <Route path="/settings" element={<Settings />} />
    </Routes>
  </Suspense>
);
Enter fullscreen mode Exit fullscreen mode

Lazy Load Heavy Libraries

// Don't import chart libraries at the top level
const ChartComponent = lazy(() => import('./components/Chart'));

// The chart library JS only loads when <ChartComponent> is rendered
const Dashboard = () => (
  <div>
    <h1>Dashboard</h1>
    <Suspense fallback={<div>Loading chart...</div>}>
      <ChartComponent data={chartData} />
    </Suspense>
  </div>
);
Enter fullscreen mode Exit fullscreen mode

5. State Management — Keep State Close

One of the biggest performance mistakes is putting everything in global state (Redux/Context). When global state updates, every connected component re-renders.

Rules of Thumb

State Type Where to Put It
Form input values Local useState
UI toggles (modals, dropdowns) Local useState
Server data (API responses) React Query / SWR
Auth / Theme Context (rarely changes)
Complex cross-component state Zustand / Jotai
// ❌ BAD — every modal open triggers global re-render
const globalState = {
  user: { ... },
  products: [...],
  isModalOpen: false,  // This doesn't belong here
  modalContent: null,  // Neither does this
};

// ✅ GOOD — modal state lives locally
const ProductPage = () => {
  const [isModalOpen, setIsModalOpen] = useState(false);
  const { data: products } = useQuery(['products'], fetchProducts);

  return (
    <>
      <ProductList products={products} onEdit={() => setIsModalOpen(true)} />
      {isModalOpen && <EditModal onClose={() => setIsModalOpen(false)} />}
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

6. Image Optimization

Images are often the heaviest assets on a page:

// Lazy load images that are below the fold
<img 
  src={product.image} 
  alt={product.name}
  loading="lazy"
  decoding="async"
  width={400}
  height={300}
/>

// Use modern formats with fallbacks
<picture>
  <source srcSet="/hero.avif" type="image/avif" />
  <source srcSet="/hero.webp" type="image/webp" />
  <img src="/hero.jpg" alt="Hero" />
</picture>
Enter fullscreen mode Exit fullscreen mode

7. Debounce Expensive Operations

Search inputs, resize handlers, and scroll listeners should be debounced:

import { useState, useMemo } from 'react';
import debounce from 'lodash.debounce';

const SearchBar = ({ onSearch }) => {
  const [query, setQuery] = useState('');

  const debouncedSearch = useMemo(
    () => debounce((q: string) => onSearch(q), 300),
    [onSearch]
  );

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setQuery(e.target.value);
    debouncedSearch(e.target.value);
  };

  return <input value={query} onChange={handleChange} placeholder="Search..." />;
};
Enter fullscreen mode Exit fullscreen mode

Quick Performance Checklist

Before shipping to production, run through this:

  • [ ] Profile with React DevTools — identify slow components
  • [ ] Bundle analyze with webpack-bundle-analyzer or vite-plugin-visualizer
  • [ ] Lighthouse audit — target 90+ performance score
  • [ ] Virtualize lists with 50+ items
  • [ ] Lazy load routes and heavy components
  • [ ] Memoize expensive computations and stable callbacks
  • [ ] Optimize images — WebP/AVIF, lazy loading, proper sizing
  • [ ] Debounce user input that triggers expensive operations

Conclusion

Performance is a feature, not an afterthought. By applying these techniques systematically — profiling first, then optimizing where it matters — you ensure your React applications remain snappy and responsive as they grow.

The key insight? Don't optimize everything. Measure first, then optimize the bottlenecks.


What performance wins have you had in your React apps? Share in the comments! 👇

For more technical deep dives, check out my blog at muhammadarslan.codes/blog or connect with me on LinkedIn and GitHub.

Top comments (0)