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.
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-devtoolsIf nothing shows up as expensive in the flame graph, you don't have a re-render problem. Stop here.
useMemo and useCallback: only for two situationsThese 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.
React.memo for expensive leaf componentsReact.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.
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.
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 buildThis 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.
| Symptom | Fix |
|---|---|
| Component re-renders on unrelated state | Profiler first, then React.memo |
| Expensive computation on every render | useMemo |
| Child re-renders because of new function refs | useCallback + React.memo together |
| First load is slow, bundle is large | next/dynamic / React.lazy |
| Don't know what's in the bundle | @next/bundle-analyzer |
| Nothing shows up as expensive in Profiler | You 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.