Why React Apps Get Slow (and How to Find Out)
React re-renders are the most common source of performance problems. When a parent component re-renders, every child component re-renders too — unless you explicitly prevent it. In a large component tree, this cascades into hundreds of unnecessary renders on every state change.
The first step to fixing performance is measuring it. Use the React DevTools Profiler:
- Open DevTools → Profiler tab → Record
- Interact with the slow part of your UI
- Stop recording and examine the flame chart
- Look for components with long render times or components that rendered when they shouldn't have
Common pattern: A global context provider (e.g., cart state) triggers re-renders in components that don't use cart data but are children of the provider. This is one of the most frequent performance bugs in React applications.
React.memo: Preventing Unnecessary Re-Renders
React.memo is a Higher Order Component that memoizes a component — it only re-renders if its props change. Use it on components that:
- Receive the same props frequently
- Are expensive to render (complex calculations or deep DOM trees)
- Are children of frequently-updating parents
const ProductCard = React.memo(function ProductCard({ product }) {
return <div>{product.name}</div>;
});
The caveat: React.memo does a shallow comparison of props. If you pass an object or function as a prop, it creates a new reference on every render — making React.memo useless. This is where useMemo and useCallback come in.
React.memo has a cost — the comparison itself takes time. Only apply it to components where the performance benefit outweighs the comparison overhead. Profile first, optimise second.useMemo and useCallback: Stable References
useMemo memoizes a computed value. useCallback memoizes a function reference. Both accept a dependency array — they only recompute when dependencies change.
useMemo for expensive calculations:
const sortedProducts = useMemo(
() => products.sort((a,b) => a.price - b.price),
[products] // only re-sorts when products array changes
);
useCallback for stable function references:
const handleAddToCart = useCallback((productId) => {
dispatch({ type: 'ADD', payload: productId });
}, [dispatch]); // stable reference — won't break React.memo
The key insight: pass handleAddToCart to a React.memo-wrapped child, and the child won't re-render just because the parent did — the function reference is stable.
Code Splitting and Lazy Loading with Suspense
Every import in your JavaScript bundle adds to the initial load time. Code splitting defers loading of non-critical code until it's actually needed.
Lazy-load routes and heavy components:
const HeavyChart = React.lazy(() => import('./HeavyChart'));
function Dashboard() {
return (
<Suspense fallback={<Spinner />}>
<HeavyChart />
</Suspense>
);
}
Good candidates for lazy loading: chart libraries (Chart.js, Recharts), rich text editors, date pickers, map components, and any feature only used by a subset of users.
npx webpack-bundle-analyzer or use Vite's built-in rollup visualiser to see what's in your bundle. You'll often find duplicated libraries or accidentally-imported large packages.Virtualisation for Long Lists
Rendering 1,000 DOM nodes is slow — even simple list items. Virtualisation renders only the items currently visible in the viewport, keeping DOM nodes to 20–50 regardless of list length.
The go-to library is TanStack Virtual (formerly react-virtual). For simpler use cases, react-window is lighter-weight. Both work with fixed-height and variable-height list items.
When to use virtualisation: any list or grid with more than 100 items — product catalogues, transaction histories, message lists, data tables.
The Performance Optimisation Checklist
Before declaring a React performance problem, work through this checklist in order:
- Profile with React DevTools Profiler to identify actual bottlenecks
- Apply
React.memoto expensive components that receive stable props - Stabilise function and object props with
useCallbackanduseMemo - Code-split heavy components and routes with
React.lazy+ Suspense - Virtualise long lists with TanStack Virtual
- Audit your bundle with a visualiser and remove unnecessary dependencies
- Move state as close to where it's used as possible to limit re-render scope
The 80/20 rule applies here: In our experience, fixing the top 2–3 most expensive render patterns in a React application typically delivers 80% of the achievable performance improvement. Profile first, fix the worst offenders, measure again.