Short answer
Measure before memoising. Most React.memo, useMemo and useCallback in a typical codebase does nothing — the dependency changes every render anyway, or the component was never expensive. Open the Profiler, find the components that actually cost milliseconds, and fix those. Re-renders are only a problem when they are slow.
React performance advice tends to arrive as a list of hooks to sprinkle. That gets the causality backwards: memoisation is not free, it adds comparison cost and dependency-array bugs, and applied blindly it makes code harder to change without making it faster.
Read the Profiler first
The React DevTools Profiler records a session and shows which components rendered, why, and how long each took. Three things to look at:
- Commit duration. A commit over roughly 16 ms drops a frame. Under that, a re-render is not worth removing.
- Why did this render? Enable it in settings. It distinguishes a props change from a state change from a parent re-render — three problems with three different fixes.
- The ranked chart. Sorts by time. The top two or three entries are usually the whole problem.
The common finding is that hundreds of components re-render and all of them are cheap, while one component doing a sort or a date-format inside render costs 40 ms. Memoising the hundred changes nothing. Fixing the one changes everything.
Why memoisation usually does not work
// React.memo compares props shallowly. Both of these are new every render,
// so the comparison always fails and memo does nothing except add work.
<Row config={{ dense: true }} onSelect={() => select(id)} />
// Stabilise the references, and only then does memo have anything to compare.
const config = useMemo(() => ({ dense: true }), []);
const onSelect = useCallback(() => select(id), [id]);
<Row config={config} onSelect={onSelect} />
React.memo on a component receiving an inline object or arrow function is the single most common no-op in React codebases. It looks like an optimisation in review and is measurably worse than nothing.
The React Compiler changes this calculation where it is adopted — it inserts memoisation automatically and correctly, which is a strong argument for removing hand-written memoisation rather than adding more.
Cheaper fixes than memoisation
Move state down
State at the top of a tree re-renders the whole tree. If only one panel uses it, the state belongs in that panel. This costs nothing and removes the re-render entirely rather than making it cheaper.
Pass children through
// Everything inside re-renders when `open` changes.
function Panel() {
const [open, setOpen] = useState(false);
return <div>{open && <ExpensiveTree />}</div>;
}
// ExpensiveTree is created by the parent and passed in as a prop, so its
// element identity is stable across Panel's state changes.
function Panel({ children }) {
const [open, setOpen] = useState(false);
return <div>{open && children}</div>;
}
Split the context
One context holding both a rarely-changing value and a frequently-changing one re-renders every consumer on every change. Two contexts, split by update frequency, fixes it without any memoisation.
Lists are where real cost lives
Rendering a thousand rows is a genuine performance problem, and no amount of memo solves it — the work is creating a thousand DOM nodes.
- Virtualise. Render only the visible window. This is the fix, and it is the difference between 20 nodes and 2,000.
- Stable keys. Array index as a key causes React to reuse the wrong DOM node when the list reorders, producing both wrong state and unnecessary work.
- Sort and filter outside render, or inside a
useMemowith correct dependencies. A sort on every keystroke of an unrelated input is a common accident.
Render cost becomes INP
Interaction to Next Paint measures how long the main thread is blocked when a user interacts. Long render work is exactly what blocks it, which is why React performance is a Core Web Vitals concern rather than a developer-experience one.
Two tools help directly. useDeferredValue lets an expensive derived view lag behind an urgent input, so typing stays responsive while the filtered list catches up. useTransition marks an update as non-urgent so React can interrupt it.
const [query, setQuery] = useState('');
const deferred = useDeferredValue(query); // lags behind during fast typing
// Input stays responsive; the expensive list renders against the deferred value.
const results = useMemo(() => filter(items, deferred), [items, deferred]);
Neither makes the work faster. They change when it happens, which is what the user perceives. The wider budget discipline is in Full-Stack Web Performance.
The largest win is usually not React
Before optimising renders, check the bundle. A date library imported for one call, a full icon set for six icons, an eagerly loaded analytics SDK — these cost more real-world milliseconds than any re-render, because parsing and executing JavaScript blocks the main thread before your components run at all.
Moving work to the server removes it from the client entirely, which is a stronger form of the same fix — see React Server Components.
Questions people actually ask
- Should I wrap every component in React.memo?
- No. It adds a props comparison on every render and does nothing unless the props are referentially stable, which inline objects and arrow functions are not. Profile, find the components that are actually expensive, and memoise those deliberately.
- Are re-renders always bad?
- No – re-rendering is how React works, and a cheap re-render is irrelevant. It becomes a problem when a commit exceeds roughly 16 ms and drops a frame, or when it blocks an interaction and shows up in INP. Judge by measured duration, not by the count in a highlight overlay.
- useMemo or useCallback – which do I need?
useMemocaches a computed value;useCallbackcaches a function reference. Use either only when the result is passed to a memoised child, used in another hook’s dependency array, or genuinely expensive to compute. Otherwise both add cost for nothing.- Does the React Compiler make this obsolete?
- Largely, for manual memoisation – it inserts the equivalent automatically and gets the dependencies right, which humans frequently do not. It does not fix architectural problems: an unvirtualised thousand-row list, a bloated bundle or a badly placed piece of state are all still yours to fix.
- Why is my list slow even after memoising rows?
- Because the cost is creating and laying out the DOM nodes, not React’s reconciliation of them. Virtualise so only the visible rows exist. Check your keys too – an index key makes React reuse the wrong nodes on reorder, which is both a correctness and a performance problem.