React is fast by default, but large component trees, expensive renders, and large bundle sizes require deliberate optimisation. The key tools are React.memo, useMemo, useCallback for render prevention, lazy/Suspense for code splitting, and virtualisation for large lists. Always measure before optimising — the React DevTools Profiler is your guide.

Key Points

  • React.memo: wraps a component and skips re-render if props are shallowly equal — only useful when parent re-renders frequently
  • useMemo(fn, deps): memoises a value; useCallback(fn, deps): memoises a function — both prevent unnecessary child re-renders
  • Code splitting: React.lazy(() => import('./Component')) + <Suspense fallback={<Spinner/>}> — loads JS on demand
  • Virtualisation: react-virtual, @tanstack/virtual, or react-window — render only visible rows in a list of thousands
  • useTransition: mark a state update as non-urgent — React can interrupt it to keep UI responsive
  • useDeferredValue: defer a value update — show stale content while expensive new content renders
  • Bundle analysis: source-map-explorer or @next/bundle-analyzer — identify large dependencies
  • Image optimisation: Next.js <Image> with automatic WebP conversion, lazy loading, and size optimisation
  • Web Vitals: LCP (Largest Contentful Paint), INP (Interaction to Next Paint), CLS (Cumulative Layout Shift) — Core Web Vitals metrics

React performance: memo + useCallback pattern, lazy/Suspense code splitting, useTransition for non-urgent updates, TanStack virtual list

import { memo, useMemo, useCallback, lazy, Suspense, useTransition,
         startTransition } from 'react';

// React.memo — skip re-render if props unchanged
const ProductRow = memo(({ product, onAdd }: ProductRowProps) => {
  console.log('ProductRow render:', product.id);
  return (
    <tr>
      <td>{product.name}</td>
      <td>{product.price}</td>
      <td><button onClick={() => onAdd(product)}>Add</button></td>
    </tr>
  );
});

function ProductTable({ products }: { products: Product[] }) {
  const [cart, setCart] = useState<Product[]>([]);

  // useCallback: stable reference so ProductRow.memo comparison passes
  const handleAdd = useCallback((product: Product) => {
    setCart(prev => [...prev, product]);
  }, []);   // no deps — function never changes

  // useMemo: expensive sort/filter
  const sorted = useMemo(
    () => [...products].sort((a, b) => a.price - b.price),
    [products]
  );

  return <table>{sorted.map(p => <ProductRow key={p.id} product={p} onAdd={handleAdd} />)}</table>;
}

// Code splitting — lazy load heavy pages
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings  = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<PageSpinner />}>
      <Routes>
        <Route path="/analytics" element={<Analytics />} />
        <Route path="/settings"  element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

// useTransition — keep input responsive during expensive filter
function SearchableList({ items }: { items: string[] }) {
  const [query, setQuery] = useState('');
  const [filtered, setFiltered] = useState(items);
  const [isPending, startTransition] = useTransition();

  function handleSearch(q: string) {
    setQuery(q);                                           // urgent: update input immediately
    startTransition(() => {                                // non-urgent: can be interrupted
      setFiltered(items.filter(i => i.includes(q)));
    });
  }

  return (
    <>
      <input value={query} onChange={e => handleSearch(e.target.value)} />
      {isPending && <Spinner size="small" />}
      <ul>{filtered.map(item => <li key={item}>{item}</li>)}</ul>
    </>
  );
}

// Virtualised list — only render visible rows
import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualList({ rows }: { rows: string[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const virtualiser = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 35,
  });

  return (
    <div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
      <div style={{ height: virtualiser.getTotalSize() }}>
        {virtualiser.getVirtualItems().map(v => (
          <div key={v.index} style={{ position: 'absolute', top: v.start, height: v.size }}>
            {rows[v.index]}
          </div>
        ))}
      </div>
    </div>
  );
}

Real-World Example

Before adding memo/useMemo/useCallback everywhere, profile with React DevTools Profiler — record a session and look for "Why did this render?". Most apps have a handful of truly expensive components; memoising everything else adds complexity with no benefit. The biggest performance wins usually come from: (1) code splitting + lazy loading, (2) virtualising long lists, (3) moving slow state out of frequently-rendering parents.