Why React Apps Get Slow (and How to Find Out)

React re-renders are the most common source of performance problems. When a parent component re-renders, every child component re-renders too — unless you explicitly prevent it. In a large component tree, this cascades into hundreds of unnecessary renders on every state change.

The first step to fixing performance is measuring it. Use the React DevTools Profiler:

  • Open DevTools → Profiler tab → Record
  • Interact with the slow part of your UI
  • Stop recording and examine the flame chart
  • Look for components with long render times or components that rendered when they shouldn't have

Common pattern: A global context provider (e.g., cart state) triggers re-renders in components that don't use cart data but are children of the provider. This is one of the most frequent performance bugs in React applications.

React.memo: Preventing Unnecessary Re-Renders

React.memo is a Higher Order Component that memoizes a component — it only re-renders if its props change. Use it on components that:

  • Receive the same props frequently
  • Are expensive to render (complex calculations or deep DOM trees)
  • Are children of frequently-updating parents

const ProductCard = React.memo(function ProductCard({ product }) {
  return <div>{product.name}</div>;
});

The caveat: React.memo does a shallow comparison of props. If you pass an object or function as a prop, it creates a new reference on every render — making React.memo useless. This is where useMemo and useCallback come in.

Don't over-memoize: React.memo has a cost — the comparison itself takes time. Only apply it to components where the performance benefit outweighs the comparison overhead. Profile first, optimise second.

useMemo and useCallback: Stable References

useMemo memoizes a computed value. useCallback memoizes a function reference. Both accept a dependency array — they only recompute when dependencies change.

useMemo for expensive calculations:

const sortedProducts = useMemo(
  () => products.sort((a,b) => a.price - b.price),
  [products] // only re-sorts when products array changes
);

useCallback for stable function references:

const handleAddToCart = useCallback((productId) => {
  dispatch({ type: 'ADD', payload: productId });
}, [dispatch]); // stable reference — won't break React.memo

The key insight: pass handleAddToCart to a React.memo-wrapped child, and the child won't re-render just because the parent did — the function reference is stable.

Code Splitting and Lazy Loading with Suspense

Every import in your JavaScript bundle adds to the initial load time. Code splitting defers loading of non-critical code until it's actually needed.

Lazy-load routes and heavy components:

const HeavyChart = React.lazy(() => import('./HeavyChart'));

function Dashboard() {
  return (
    <Suspense fallback={<Spinner />}>
      <HeavyChart />
    </Suspense>
  );
}

Good candidates for lazy loading: chart libraries (Chart.js, Recharts), rich text editors, date pickers, map components, and any feature only used by a subset of users.

Bundle analysis: Run npx webpack-bundle-analyzer or use Vite's built-in rollup visualiser to see what's in your bundle. You'll often find duplicated libraries or accidentally-imported large packages.

Virtualisation for Long Lists

Rendering 1,000 DOM nodes is slow — even simple list items. Virtualisation renders only the items currently visible in the viewport, keeping DOM nodes to 20–50 regardless of list length.

The go-to library is TanStack Virtual (formerly react-virtual). For simpler use cases, react-window is lighter-weight. Both work with fixed-height and variable-height list items.

When to use virtualisation: any list or grid with more than 100 items — product catalogues, transaction histories, message lists, data tables.

The Performance Optimisation Checklist

Before declaring a React performance problem, work through this checklist in order:

  1. Profile with React DevTools Profiler to identify actual bottlenecks
  2. Apply React.memo to expensive components that receive stable props
  3. Stabilise function and object props with useCallback and useMemo
  4. Code-split heavy components and routes with React.lazy + Suspense
  5. Virtualise long lists with TanStack Virtual
  6. Audit your bundle with a visualiser and remove unnecessary dependencies
  7. Move state as close to where it's used as possible to limit re-render scope

The 80/20 rule applies here: In our experience, fixing the top 2–3 most expensive render patterns in a React application typically delivers 80% of the achievable performance improvement. Profile first, fix the worst offenders, measure again.