Skip to content

React – Performance

Performance optimisation in React follows one rule above all others: measure first, optimise second. Adding useMemo and useCallback everywhere without evidence of a problem makes code harder to read and often makes performance worse.

The React 19 Compiler — Read This First

Manual memoisation may be redundant on your project

The React Compiler (stable in React 19) automatically memoises components and hook values at build time. On projects where it is enabled, manually adding useMemo, useCallback, and React.memo() is usually unnecessary and adds noise.

Before adding any manual memoisation: 1. Check package.json — is babel-plugin-react-compiler or eslint-plugin-react-compiler present? 2. If yes — profile with React DevTools first. Add manual memoisation only if profiling shows a real issue. 3. If no — the guidance below applies in full.

Identifying Performance Problems

Never optimise without evidence. Use these tools to find real bottlenecks.

React DevTools Profiler

The React DevTools Profiler records renders and shows which components re-rendered, how long they took, and why. Install the browser extension and use the Profiler tab.

What to look for: - Components re-rendering more often than expected - Render times above 16ms (causes dropped frames at 60fps) - Long "commit" phases indicating expensive renders

Why Did You Render

Install @welldone-software/why-did-you-render to log unexpected re-renders during development.

// wdyr.ts — import this file first in your entry point (development only)
import React from 'react'

if (process.env.NODE_ENV === 'development') {
  const whyDidYouRender = require('@welldone-software/why-did-you-render')
  whyDidYouRender(React, {
    trackAllPureComponents: true,
  })
}

Memoisation — When It Actually Helps

React.memo

Wrap a component in React.memo only when it is a pure presentational component that receives the same props frequently and is expensive to render.

// ✅ Good candidate — pure display component, receives stable props
interface OrderRowProps {
  order: Order
  onCancel: (id: string) => void
}

const OrderRow = React.memo(({ order, onCancel }: OrderRowProps) => {
  return (
    <tr>
      <td>{order.reference}</td>
      <td>{formatCurrency(order.total, order.currency)}</td>
      <td>
        <button onClick={() => onCancel(order.id)}>Cancel</button>
      </td>
    </tr>
  )
})

OrderRow.displayName = 'OrderRow'   // always set displayName for memoised components

// ❌ Pointless — component is already cheap to render
const Label = React.memo(({ text }: { text: string }) => <span>{text}</span>)

React.memo only helps when props are stable

React.memo does a shallow comparison of props. If the parent passes a new object, array, or function on every render, React.memo provides no benefit. Pair it with useCallback for function props and stable references for object props.

useMemo

// ✅ Good candidate — expensive sort/filter over a large array
const filteredAndSortedOrders = useMemo(
  () =>
    orders
      .filter(order => order.status === activeFilter)
      .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
  [orders, activeFilter]
)

// ✅ Good candidate — derived data used by multiple children
const ordersByStatus = useMemo(
  () =>
    orders.reduce<Record<string, Order[]>>((acc, order) => {
      acc[order.status] = [...(acc[order.status] ?? []), order]
      return acc
    }, {}),
  [orders]
)

// ❌ Not worth it — simple string interpolation
const title = useMemo(() => `Order #${order.reference}`, [order.reference])
// Just write: const title = `Order #${order.reference}`

// ❌ Not worth it — cheap boolean derivation
const isEmpty = useMemo(() => orders.length === 0, [orders])
// Just write: const isEmpty = orders.length === 0

useCallback

// ✅ Good candidate — callback passed to a React.memo child
const handleCancel = useCallback(
  (orderId: string) => {
    cancelOrder(orderId)
  },
  [cancelOrder]
)

// ✅ Good candidate — callback in a useEffect dependency array
useEffect(() => {
  const unsubscribe = eventBus.on('order:updated', handleOrderUpdate)
  return () => unsubscribe()
}, [handleOrderUpdate])   // stable reference prevents infinite re-subscription

// ❌ Not worth it — callback not passed to a memoised child
const handleClick = useCallback(() => setIsOpen(true), [])
// Adds overhead with no benefit if the parent re-renders anyway

Code Splitting and Lazy Loading

Split large components and routes so they are only loaded when needed. This reduces the initial bundle size and improves Time to Interactive.

// ✅ Route-level code splitting — load pages only when navigated to
import { lazy, Suspense } from 'react'

const OrdersPage = lazy(() => import('./pages/OrdersPage'))
const ReportsPage = lazy(() => import('./pages/ReportsPage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))

const AppRouter = () => (
  <Suspense fallback={<PageLoadingSpinner />}>
    <Routes>
      <Route path="/orders" element={<OrdersPage />} />
      <Route path="/reports" element={<ReportsPage />} />
      <Route path="/settings" element={<SettingsPage />} />
    </Routes>
  </Suspense>
)

// ✅ Component-level lazy loading — heavy components loaded on demand
const RichTextEditor = lazy(() => import('./components/RichTextEditor'))
const DataExportModal = lazy(() => import('./components/DataExportModal'))

const OrderNotes = ({ orderId }: { orderId: string }) => {
  const [isEditing, setIsEditing] = useState(false)

  return (
    <div>
      <button onClick={() => setIsEditing(true)}>Edit notes</button>
      {isEditing && (
        <Suspense fallback={<div>Loading editor...</div>}>
          <RichTextEditor orderId={orderId} />
        </Suspense>
      )}
    </div>
  )
}

Avoid Inline Object and Function Creation in JSX

Creating new object literals, array literals, or arrow functions inside JSX causes unnecessary re-renders because a new reference is created on every render.

// ❌ Incorrect — new object created on every render
<Chart style={{ color: 'blue', margin: '0 auto' }} />
<UserList filters={{ status: 'active', page: 1 }} />

// ✅ Correct — stable reference outside the render
const chartStyle = { color: 'blue', margin: '0 auto' }   // outside component, or useMemo
const defaultFilters = { status: 'active', page: 1 }

<Chart style={chartStyle} />
<UserList filters={defaultFilters} />

// ❌ Incorrect — new function created on every render, breaks React.memo child
<OrderRow order={order} onCancel={(id) => cancelOrder(id)} />

// ✅ Correct — stable callback reference
const handleCancel = useCallback((id: string) => cancelOrder(id), [cancelOrder])
<OrderRow order={order} onCancel={handleCancel} />

Virtualising Long Lists

Never render all items in a list with hundreds or thousands of rows. Render only the visible rows using a virtual list.

Use TanStack Virtual (or react-window for legacy projects):

import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'

interface VirtualOrderListProps {
  orders: Order[]
}

const VirtualOrderList = ({ orders }: VirtualOrderListProps) => {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: orders.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 72,     // estimated row height in px
    overscan: 5,                // render 5 extra rows above and below viewport
  })

  return (
    <div
      ref={parentRef}
      style={{ height: '600px', overflow: 'auto' }}
    >
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map(virtualItem => (
          <div
            key={virtualItem.key}
            data-index={virtualItem.index}
            ref={virtualizer.measureElement}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            <OrderRow order={orders[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  )
}

Image Optimisation

// ✅ Next.js — use next/image for automatic optimisation, lazy loading, sizing
import Image from 'next/image'

<Image
  src={user.avatarUrl}
  alt={`${user.name}'s avatar`}
  width={48}
  height={48}
  priority={isAboveFold}     // set priority for above-the-fold images
/>

// ✅ Non-Next.js — use loading="lazy" and explicit dimensions
<img
  src={product.imageUrl}
  alt={product.name}
  width={320}
  height={240}
  loading="lazy"
  decoding="async"
/>

// ❌ Missing dimensions — causes layout shift (bad CLS score)
<img src={product.imageUrl} alt={product.name} />

Bundle Analysis

Run bundle analysis before every major release to catch unexpected size regressions.

# Next.js
npm install @next/bundle-analyzer
ANALYZE=true next build

# Vite
npm install rollup-plugin-visualizer
# Add to vite.config.ts: visualizer({ open: true })
vite build

Bundle size targets:

Asset Target Action if exceeded
Initial JS bundle < 200 KB gzipped Audit imports, code split more aggressively
Total JS (all chunks) < 1 MB gzipped Review third-party library choices
Largest chunk < 100 KB gzipped Split further or lazy-load

Common culprits to check:

  • Importing the entire lodash instead of individual functions (lodash/get vs import { get } from 'lodash')
  • Importing full date-fns, moment, or dayjs when only a few functions are used
  • Including entire icon libraries (react-icons, @heroicons/react) instead of individual icons
  • Duplicate dependencies at different versions (run npm ls <package> to check)

Performance Quick Reference

Technique When to apply When NOT to apply
React.memo Pure component, expensive render, receives stable props Cheap components, components that always re-render
useMemo Expensive computation (sort, filter, aggregation over large arrays) Simple expressions, string interpolation, boolean checks
useCallback Callback passed to React.memo child; function in useEffect deps Handlers used only by non-memoised children
React.lazy + Suspense Routes, heavy modals, rarely-used heavy components Small, frequently-used components
Virtual list Lists > 100 items Short lists (< 50 items)
loading="lazy" Below-the-fold images Above-the-fold (hero) images — use priority instead