Performance
memo, lazy, Suspense, code splitting, virtualisation, profiler
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.