React's reconciliation algorithm is remarkably efficient — but it only optimises within a tree it understands. When state updates cascade through poorly structured component trees, triggering unnecessary re-renders of expensive components, the result is an application that feels sluggish despite technically running on fast hardware. Most React performance problems are architectural, not algorithmic.
Diagnosing Before Optimising
Before reaching for useMemo and useCallback, profile first. React DevTools' Profiler tab shows exactly which components rendered, why they rendered, and how long they took. Sort by "Render duration" and address the outliers before attempting micro-optimisations across the entire tree. The Profiler frequently reveals that the real bottleneck is a single component making a synchronous API call, not the dozens of innocent components you were about to unnecessarily memoise.
Strategic Memoisation
React.memo, useMemo, and useCallback all prevent unnecessary re-computation — but each has a cost: the memoised value must be stored, and the dependency comparison must run on every render. Memoisation is only worth it when the computation cost exceeds the comparison cost. Good candidates: components that render large lists, expensive derived computations (filtering 10,000 records), and callback functions passed to deeply nested child components that would otherwise re-render unnecessarily on every parent render.
Code Splitting and Lazy Loading
The single highest-impact optimisation for initial page load is reducing the JavaScript bundle. React.lazy() with Suspense defers loading of route-level components until they're navigated to. Next.js handles this automatically per-page, but in-page lazy loading of heavy modals, charts, or rich text editors can significantly reduce the initial bundle. The rule: if a component isn't visible on initial load, it shouldn't be in the initial bundle.
- Virtualise long lists: Rendering 10,000 DOM nodes is slow;
@tanstack/virtualrenders only the visible rows. - State colocation: Move state as close to where it's used as possible. Global state updates re-render every subscriber.
- Concurrent features: Wrap non-urgent state updates in
startTransition()to keep the UI responsive during heavy renders.