-
Measure before you touch anything — never memoize on a hunch. Manual memoization is a tradeoff (extra comparisons + cache memory); applied blindly it often makes things slower and always makes them harder to read. Get evidence first:
| Tool | What it tells you | How |
|---|
| React DevTools Profiler | which components rendered, how long, why | record an interaction → flamegraph + ranked chart |
| "Highlight updates when components render" (DevTools → ⚙ → Components) | visual flash on every render — instant "this re-renders on every keystroke" signal | toggle on, interact |
why-did-you-render | logs the exact prop/state/hook that changed (and whether it was a deep-equal-but-referentially-different value) | dev-only, see step 2 |
React 19 <Profiler onRender> / performance.measure | programmatic render timings in tests/CI | wrap a subtree |
In the Profiler, enable "Record why each component rendered" (⚙ → Profiler). Re-renders show a reason: props changed, hooks changed, parent rendered, context changed. That reason picks the fix below — don't guess.
-
Wire up why-did-you-render to catch referential-equality bugs (dev only). It surfaces the classic "props are deep-equal but a new object/array/function identity every render" case that React.memo can't catch.
import React from 'react';
if (process.env.NODE_ENV === 'development') {
const wdyr = require('@welldone-software/why-did-you-render');
wdyr(React, { trackAllPureComponents: true, collapseGroups: true });
}
A log like "props.style changed: ({}) → ({}) (different objects that are equal by value)" means: hoist the literal or memoize the reference — not wrap the child in memo.
-
If you're on React 19, turn on the React Compiler FIRST — it auto-memoizes and makes most manual memo dead weight. The compiler (babel-plugin-react-compiler, also in the Next.js / Vite plugin) memoizes components and hook values automatically at build time, so useMemo/useCallback/React.memo become largely redundant. Before hand-tuning:
- Install and enable it; run
npx react-compiler-healthcheck to see how many components are compatible (it skips components that break the Rules of React — those are your real bugs to fix).
- Don't mix strategies blindly: keep code Rules-of-React-clean (no mutation of props/state, no conditional hooks) so the compiler can optimize. New manual
useMemo/useCallback you add on top is usually noise. Lean on <StrictMode> to surface impurity.
- The compiler does not fix algorithmic problems (huge lists, O(n²) renders) — you still need virtualization (step 9) and correct keys (step 7). Treat the rest of these steps as either pre-19 work or the things the compiler can't do.
-
Kill unstable references at the source — this is the #1 cause of "memo doesn't work". React.memo and dep arrays compare by reference (Object.is). A new {}, [], or arrow function created in render is a new identity every time, so it busts every downstream memo and effect. Fix the source, don't paper over it:
| Anti-pattern (new identity each render) | Fix |
|---|
<Child style={{ margin: 8 }} /> | hoist the object to a module-level const (it never changes) |
<Child onClick={() => doX(id)} /> passed to a memoized child | useCallback(() => doX(id), [id]) |
const opts = { a, b } then used in a dep array | useMemo(() => ({ a, b }), [a, b]) |
data.filter(...) computed inline each render into a memoized child | useMemo(() => data.filter(...), [data]) |
default prop items = [] (new array each call) | hoist const EMPTY = [] and default to it |
Static values (handlers with no closure deps, constant config) belong outside the component entirely — zero runtime cost.
-
Use useCallback/useMemo only where a referentially-equal value actually changes behavior — otherwise skip it. They are not free: each stores a closure + dep array and runs an equality check every render. They earn their keep in exactly these cases — and nowhere else:
- The memoized value/callback is a dependency of another hook (
useEffect, useMemo) where a changing identity would refire the effect.
- It's passed as a prop to a
React.memo'd child (otherwise the child's memo is pointless).
useMemo wraps a genuinely expensive computation (sort/filter of thousands, parse, heavy derive) — measure; "expensive" is rarely a .map over 20 items.
Skip them when the consumer isn't memoized, the deps change every render anyway (then the cache never hits — pure overhead), or the body is cheap. A useCallback whose result flows only into a non-memoized DOM <button onClick> does nothing useful.
-
React.memo only the genuinely-hot leaf, and give it the right comparator. memo skips a re-render when props are shallow-equal. Apply it to a component that (a) renders often due to parent re-renders, (b) is expensive or numerous (list rows), and (c) gets stable props (you did step 4). Without stable props it's worse than nothing.
- Default shallow compare is correct most of the time. Provide a custom
areEqual(prev, next) only for a known-shape prop where shallow misses (e.g. compare prev.item.id === next.item.id && prev.selected === next.selected) — and keep it cheaper than the render it saves.
memo compares props only — it does not stop re-renders from internal useState/useContext changes. If the offender is context (step 8) or state, memo won't help.
const Row = memo(function Row({ item, onSelect }) { },
(a, b) => a.item.id === b.item.id && a.item.label === b.item.label && a.onSelect === b.onSelect);
-
Fix list keys — wrong keys force remounts and break memoized rows. Use a stable, unique id from the data (item.id), never the array index for any list that can reorder, insert, or delete. Index keys make React reuse the wrong DOM/state on reorder (lost input focus, wrong row highlighted) and defeat per-row memo. Don't use Math.random()/uuid() in render either — a new key every render remounts the row each time. Stable identity → React diffs in place → memoized rows actually skip.
-
Stop context from re-rendering the whole subtree — split it or use a selector. Every consumer of a context re-renders whenever any field of the context value changes, even fields it doesn't read. Three escalating fixes:
| Technique | When | How |
|---|
| Memoize the provider value | value is {}/[] literal inline | value={useMemo(() => ({ user, setUser }), [user])} — without this every consumer re-renders every parent render |
| Split contexts by change frequency | one context mixes hot + cold data (e.g. theme + live cursorPosition) | separate providers; a component subscribes only to what it uses → high-frequency updates don't touch theme consumers |
| Selector subscription | consumers read different slices of a big store | useSyncExternalStore with a selector, use-context-selector, or a store lib (Zustand: useStore(s => s.field), Redux: useSelector) — re-render only when the selected slice changes |
Splitting state-vs-dispatch contexts is a cheap classic win: a component that only dispatches never re-renders on state changes.
-
Virtualize long lists instead of memoizing 10,000 rows. No amount of memo saves you from mounting thousands of DOM nodes. Render only the visible window (+overscan) with TanStack Virtual (@tanstack/react-virtual, headless, framework-agnostic) or react-virtuoso. It computes which rows intersect the viewport and absolutely-positions them; DOM stays ~constant regardless of dataset size. Combine with stable keys (step 7) and a memoized row. This is the fix when the count is the problem, not per-row work.
-
Derive, don't store — redundant state is redundant renders. Anything computable from props/existing state during render should be computed in render (optionally useMemo'd), not held in its own useState synced via useEffect. The useState+useEffect-to-sync pattern adds an extra render every change and drifts out of sync. Likewise: lift state down (push it into the smallest component that needs it so updates don't re-render siblings), and split a god-component so a chatty piece of state isn't wired into a large subtree. Fewer state cells in fewer places = fewer renders.
-
Don't over-memoize — premature memoization has a real cost. Each memo/useMemo/useCallback adds an equality check + retained closure + cognitive load, and a wrong dep array becomes a stale-closure bug. Rules of thumb: leave cheap components un-memoized; never wrap a component whose props are unstable (fix the props instead); never memoize purely to "be safe." On React 19, prefer the compiler over manual memo. Memoize when the Profiler shows a measured hot path — not before. Remove memos that the Profiler shows aren't being hit.
Done = the measured re-render(s) the Profiler flagged are eliminated by the matching fix (stable refs, correct keys, context split/selector, derive-don't-store, or virtualization), every remaining manual memo is justified by evidence (or replaced by the React 19 compiler), and the before/after Profiler traces prove the churn is gone — not added overhead.