TheThunderclap
JavaScript React Performance Frontend

React Performance Patterns

useMemo, useCallback, lazy loading, and virtualization — practical patterns to fix React performance at scale.

N

Neha Choudhary

Frontend Engineer

📅 20 January 2025
⏱ 8 min read

Introduction

React's component model is intuitive, but it can lead to subtle performance problems as your app grows — excessive re-renders, large bundle sizes, and memory leaks are the most common culprits. This guide covers the patterns that actually move the needle.

Memoization: useMemo & useCallback

Both hooks prevent re-computation on every render. The key rule: don't use them indiscriminately. The memo itself has a cost. Only apply when:

The computation is genuinely expensive You need referential equality (callbacks passed to child components, dependencies in other hooks)

memoization.tsx
tsx
                                            "hl-keyword">import { useMemo, useCallback, memo } "hl-keyword">from 'react';

"hl-keyword">class="hl-comment">// ❌ Pointless — addition is cheaper than the memo
"hl-keyword">const bad = useMemo(() => a + b, [a, b]);

"hl-keyword">class="hl-comment">// ✅ Expensive filter on a large list
"hl-keyword">const filteredItems = useMemo(
  () => items.filter(item => item.category === activeCategory),
  [items, activeCategory]  "hl-keyword">class="hl-comment">// only re-runs when these change
);

"hl-keyword">class="hl-comment">// ✅ Stable callback reference — prevents child re-renders
"hl-keyword">const handleDelete = useCallback(
  (id: "hl-type">string) => {
    dispatch({ "hl-keyword">type: 'DELETE_ITEM', payload: id });
  },
  [dispatch]  "hl-keyword">class="hl-comment">// dispatch "hl-keyword">from useReducer is already stable
);

"hl-keyword">class="hl-comment">// Wrap child with memo so it only re-renders when props change
"hl-keyword">const ItemRow = memo(({ item, onDelete }: Props) => (
  <li>
    {item.name}
    <button onClick={() => onDelete(item.id)}>Delete</button>
  </li>
));
                                        

Code Splitting & Lazy Loading

React.lazy + Suspense loads components only when they're needed. This can cut your initial bundle size by 40–70% for complex apps with multiple routes.

lazy-routes.tsx
tsx
                                            "hl-keyword">import { lazy, Suspense } "hl-keyword">from 'react';
"hl-keyword">import { Routes, Route } "hl-keyword">from 'react-router-dom';

"hl-keyword">class="hl-comment">// These components are loaded ONLY when the route is visited
"hl-keyword">const Dashboard = lazy(() => "hl-keyword">import('./pages/Dashboard'));
"hl-keyword">const Settings  = lazy(() => "hl-keyword">import('./pages/Settings'));
"hl-keyword">const Editor    = lazy(() => "hl-keyword">import('./pages/Editor'));

"hl-keyword">function App() {
  "hl-keyword">return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/"          element={<Dashboard />} />
        <Route path="/settings"  element={<Settings />} />
        <Route path="/editor"    element={<Editor />} />
      </Routes>
    </Suspense>
  );
}

"hl-keyword">class="hl-comment">// Prefetch on hover — load the chunk before the user clicks
"hl-keyword">function NavLink({ to, children }: Props) {
  "hl-keyword">const preload = () => "hl-keyword">import(`./pages/${to}`);
  "hl-keyword">return (
    <a href={to} onMouseEnter={preload}>
      {children}
    </a>
  );
}
                                        

Virtualisation for Long Lists

Never render thousands of DOM nodes. Use virtualisation (also called windowing) to render only the visible rows. @tanstack/virtual is the modern choice.

virtual-list.tsx
tsx
                                            "hl-keyword">import { useVirtualizer } "hl-keyword">from '@tanstack/react-virtual';
"hl-keyword">import { useRef } "hl-keyword">from 'react';

"hl-keyword">function VirtualList({ items }: { items: "hl-type">string[] }) {
  "hl-keyword">const parentRef = useRef<HTMLDivElement>("hl-type">null);

  "hl-keyword">const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 56,   "hl-keyword">class="hl-comment">// estimated row height "hl-keyword">in px
    overscan: 5,              "hl-keyword">class="hl-comment">// extra rows rendered outside viewport
  });

  "hl-keyword">return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      {/* Container with the full list height so the scrollbar is correct */}
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map(virtualRow => (
          <div
            key={virtualRow.index}
            style={{
              position: 'absolute',
              top: virtualRow.start,
              height: virtualRow.size,
              width: '100%',
            }}
          >
            {items[virtualRow.index]}
          </div>
        ))}
      </div>
    </div>
  );
}
                                        

💬 Comments

0 comments

Leave a comment

0/1000

Comments are moderated. Be respectful. ✌️

📚 Related Articles