The most common performance advice in React is also the least measured: "wrap it in useMemo." Here is the part the advice skips. Memoization is not free. On every render, React compares every dependency with Object.is and keeps the cached value alive in memory. A useMemo wrapping two lines of array mapping spends more time checking dependencies than the mapping ever cost.
I have untangled plenty of "optimized" components that were slower with memoization than without. Not because memoization is bad, but because it was applied as superstition instead of measurement.
Step 1: Measure before you memo
Open the profiler, record the interaction that feels slow, and look at actual commit times. If a component renders in 0.3ms, no hook inside it matters. The expensive renders are visible in the flame chart as wide bars, and those are the only places memoization can pay.
The habit: no useMemo goes into code until a profiler showed me that specific computation showing up in real commits. Guesswork memoization is the caching equivalent of buying faster servers for a slow database query.
Step 2: Memoize referential equality, not CPU time
The highest-value use of these hooks is not saving milliseconds. It is keeping a reference stable so other things stop re-rendering or re-running:
const filter = useMemo(() => ({ status, minPrice }), [status, minPrice]);
const onSelect = useCallback((id: string) => {
setSelected(id);
}, []);useMemo here means a <FilterPanel /> wrapped in memo skips rendering when unrelated state changes, because its prop did not become a fresh object. useCallback on a function passed into a dependency array stops an effect from re-running every render.
That is the real economy: stable references, not saved math.
Step 3: Keep dependency arrays honest
A memo that lies is worse than no memo:
// reads cart.items, depends on nothing
const total = useMemo(() => cart.items.reduce(sum), []);The empty dependency array freezes the first total forever. useMemo is a promise: this computation depends only on these inputs. If you cannot make that promise true, the lint rule will scream, the value will go stale, and the bug will appear far from the line that caused it. An honestly dependent useMemo with an empty win is still better than a dishonest one, but the honest fix is usually deleting the hook and computing inline.
Step 4: Do not memo what re-renders anyway
useCallback on every handler passed to every child is a common reflex. It only matters when all three hold: the child is wrapped in memo, the child actually renders often, and its other props stay stable too. Miss any one and the callback memo does nothing while the code gets harder to read.
// pointless if ListItem is not memo()
const handleSelect = useCallback((id) => onSelect(id), [onSelect]);Memoizing props for a child that re-renders regardless is carrying water uphill. Wrap the child, stabilize the props, then verify in the profiler that the child's renders actually disappeared. Half-measures get the syntax and keep the renders.
Step 5: Push work up instead of memoizing it down
Sometimes the right answer is not a hook. If a computation is genuinely expensive:
- Compute it on the server, in a loader, and pass the result down
- Move it into a selector library (Reselect, TanStack Query's cache) that has real caching semantics
- Derive it in the component that owns the data instead of re-deriving in every consumer
React's own docs have drifted to this position: most useMemo and useCallback in app code can be deleted with zero measurable difference. The remaining cases are exactly steps 2 and 3, and they are worth their rent.
The rule I actually follow
Memoize when the profiler points, or when a stable reference breaks a real re-render chain. Everything else computes inline, stays readable, and stays correct. Deleting an unnecessary useMemo has fixed more bugs for me than adding one ever has.
Profiling a React app that feels slow no matter how many hooks it has? I do these audits regularly. Tell me about the app and I will show you where the renders actually go.