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
lodashinstead of individual functions (lodash/getvsimport { get } from 'lodash') - Importing full
date-fns,moment, ordayjswhen 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 |