← Back to Blog

React Performance: Memoization, Code Splitting, and Bundle Analysis

2026-07-04·3 min read
ReactPerformanceJavaScriptNext.jsWeb Development

The default reaction to "the app feels slow" is to wrap everything in useMemo and useCallback. That's usually wasted effort: you're optimizing code you haven't measured, and memoization itself has a cost (extra comparisons, extra memory). Profile first, then pick the right tool for what's actually slow: re-renders, or bundle size.

1. Find the re-render first

Before touching any code, open React DevTools → Profiler, record an interaction, and look at what re-rendered and why. Every component has a "why did this render" reason listed: state change, prop change, parent re-render, context change. That's the only reliable starting point.

# React DevTools ships as a browser extension
# Or standalone for React Native / non-browser environments:
npm install -g react-devtools

If nothing shows up as expensive in the flame graph, you don't have a re-render problem. Stop here.

2. useMemo and useCallback: only for two situations

These hooks solve exactly two problems, not general "slowness":

A. Expensive computation you don't want to redo on every render

// Without memo: recalculates on every keystroke in the search box,
// even though `items` hasn't changed
const sorted = items.slice().sort(expensiveCompare);
 
// With memo: only recalculates when `items` actually changes
const sorted = useMemo(() => items.slice().sort(expensiveCompare), [items]);

B. Referential stability for a dependency array or a memoized child

// Without useCallback: a new function reference every render,
// which breaks React.memo on <ExpensiveChild />
const handleClick = () => doSomething(id);
 
// With useCallback: same reference until `id` changes
const handleClick = useCallback(() => doSomething(id), [id]);

If neither applies (the computation is cheap, or nothing downstream cares about reference identity), the memoization is pure overhead. React re-runs the comparison function on every render either way.

3. React.memo for expensive leaf components

React.memo skips a re-render entirely if props are shallow-equal. It's most useful on components that render a lot of DOM (tables, lists, charts) and sit below a parent that re-renders often for unrelated reasons.

const Row = React.memo(function Row({ item, onSelect }) {
  return <tr onClick={() => onSelect(item.id)}>{item.name}</tr>;
});

This only works if item and onSelect are referentially stable, which is exactly why useMemo/useCallback and React.memo are almost always used together, not in isolation.

4. Code splitting: ship less JavaScript on first load

Re-renders are a runtime problem. Bundle size is a load-time problem, and it's usually the bigger win for real users on real networks. next/dynamic (or React.lazy outside Next.js) splits a component into its own chunk, loaded only when needed.

import dynamic from "next/dynamic";
 
// Chart library is 200kb+, only needed on the analytics tab
const Chart = dynamic(() => import("./Chart"), {
  loading: () => <Skeleton />,
  ssr: false, // skip server render if the lib needs `window`
});

Good candidates: modals, chart/rich-text libraries, admin-only panels, anything gated behind a route or a tab that most sessions never open.

5. Measure the bundle, don't guess

npm install -D @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require("@next/bundle-analyzer")({
  enabled: process.env.ANALYZE === "true",
});
module.exports = withBundleAnalyzer({});
ANALYZE=true npm run build

This opens a treemap of what's actually in your JS bundles. The usual offenders: a full date library imported for one format call, an icon package imported as a whole instead of per-icon, a moment.js when date-fns or Intl would do.

Quick reference

SymptomFix
Component re-renders on unrelated stateProfiler first, then React.memo
Expensive computation on every renderuseMemo
Child re-renders because of new function refsuseCallback + React.memo together
First load is slow, bundle is largenext/dynamic / React.lazy
Don't know what's in the bundle@next/bundle-analyzer
Nothing shows up as expensive in ProfilerYou don't have a performance problem yet

The order matters: profile, then memoize what's actually expensive, then split what's actually large. Skipping straight to useMemo everywhere adds comparison overhead without fixing anything you've verified is slow.