When `memo` Actually Stops a Re-render (and When It Does Not)
I once added React.memo everywhere and renders barely changed. Memo only works under specific conditions, and outside those it is dead weight. Three accordions on the trap and the fix.
By @ananyaadeyemi
January 8, 2026
·
Updated August 10, 2026
980 views
7
4.2 (12)
// The case memo handles well: a child that takes primitive props and gets
// rendered repeatedly by a parent whose unrelated state is changing. Object.is
// on each prop confirms nothing changed, and the child render is skipped.
// Tiny memo stand-in: shallow-compare props, return cached result on a hit.
function memo(component) {
let lastProps = null;
let lastResult = null;
return function MemoWrapper(props) {
if (lastProps && shallowEqual(lastProps, props)) return lastResult;
lastProps = props;
lastResult = component(props);
return lastResult;
};
}
function shallowEqual(a, b) {
if (a === b) return true;
if (a == null || b == null) return false;
const ak = Object.keys(a);
if (ak.length !== Object.keys(b).length) return false;
for (const k of ak) if (!Object.is(a[k], b[k])) return false;
return true;
}
let renderCount = 0;
function Greeting(props) {
renderCount++;
return 'Hello, ' + props.name + ' (renders: ' + renderCount + ')';
}
const MemoGreeting = memo(Greeting);
// Parent's unrelated state ticks. Child's only prop is a primitive that does not change.
let parentState = 0;
function parentRender() {
parentState++;
return MemoGreeting({ name: 'Ada' });
}
console.log(parentRender());
console.log(parentRender());
console.log(parentRender());
console.log('parent rendered 3 times, child renders:', renderCount);
console.log('memo skipped 2 renders because { name: "Ada" } is shallow-equal each time.');
// When the prop genuinely changes, memo correctly lets the render through.
function parentRenderWithName(name) {
return MemoGreeting({ name });
}
console.log(parentRenderWithName('Ada')); // hit, no new render
console.log(parentRenderWithName('Linus')); // miss, child re-renders
console.log(parentRenderWithName('Linus')); // hit again
console.log('child total renders:', renderCount);This is the case the React docs imply when they introduce memo: the parent re-renders, the child receives the same primitive props, the shallow comparison hits, the child is skipped. The render counter at the bottom is the load-bearing thing to look at. Three parent renders produced one child render, two parent renders with 'Ada' then one with 'Linus' produced one more. If your memo'd components are this shape, memo is doing exactly what you expect. The trick is that almost no real component is this shape, which is what the next accordion is about.
// The same memo, the same Greeting, but the parent passes a new object/array/
// callback literal on every render. Shallow comparison always misses, the
// child always re-renders, and memo is now pure overhead: an extra prop walk
// for zero saved work.
function memo(component) {
let lastProps = null;
let lastResult = null;
let comparisonsRun = 0;
const wrapped = function MemoWrapper(props) {
if (lastProps) {
comparisonsRun++;
if (shallowEqual(lastProps, props)) return lastResult;
}
lastProps = props;
lastResult = component(props);
return lastResult;
};
wrapped.stats = () => ({ comparisonsRun });
return wrapped;
}
function shallowEqual(a, b) {
if (a === b) return true;
if (a == null || b == null) return false;
const ak = Object.keys(a);
if (ak.length !== Object.keys(b).length) return false;
for (const k of ak) if (!Object.is(a[k], b[k])) return false;
return true;
}
let renderCount = 0;
function UserCard(props) {
renderCount++;
return 'card for ' + props.user.name + ' [renders=' + renderCount + ']';
}
const MemoUserCard = memo(UserCard);
const userId = 1; // never changes during this run
for (let i = 0; i < 5; i++) {
// Object literal! Fresh reference every render.
const user = { id: userId, name: 'Ada' };
// Inline arrow! Fresh function every render.
const onSelect = () => { /* ... */ };
const items = [1, 2, 3]; // fresh array literal too
console.log(MemoUserCard({ user, onSelect, items }));
}
console.log('child renders after 5 parent renders:', renderCount);
console.log('comparisons run:', MemoUserCard.stats().comparisonsRun);
console.log('Result: memo is dead weight here. Same data, same logical props, every comparison missed.');
// The diagnostic question: are any of my props non-primitive AND defined inline
// at the call site? If yes, memo will not help until the props are stabilized.
const exampleProps = { user: { id: 1 }, onSelect: () => {}, items: [1, 2] };
console.log('non-primitive props in this call:',
Object.entries(exampleProps).filter(([, v]) => typeof v === 'object' || typeof v === 'function').map(([k]) => k));The bug pattern is the most common one in any codebase that has been around for more than a year. <UserCard user={{ id, name }} onSelect={() => handleSelect(id)} items={[1, 2, 3]} /> looks fine: the data is logically the same on every render, after all. But each of those literals is a fresh allocation, so shallow-compare sees three different references and the memo always misses. Worse, the comparison itself costs more than the render it failed to skip for very small components, so the net effect is slower than no memo at all. The diagnostic at the bottom is the question I ask first whenever someone tells me memo is not working: any object, array, or function defined inline at the call site has to be stabilized before memo can help.
// Two complementary fixes, both of which I reach for. Pick by props shape.
// (a) Stabilize references at the parent: useMemo for objects/arrays,
// useCallback for functions. Memo's default shallow compare then works.
// (b) Pass a custom equality function to memo for cases where deep equal is
// cheaper than the render you would otherwise pay. Used sparingly.
// Tiny hook harness: a per-render "slot cursor" so useMemo/useCallback look up
// the same slot across renders, mimicking React's positional hook model.
const hookState = { slots: [], cursor: 0 };
function beginRender() { hookState.cursor = 0; }
function useMemo(fn, deps) {
const id = hookState.cursor++;
const prev = hookState.slots[id];
const equal = prev && prev.deps.length === deps.length
&& prev.deps.every((d, i) => Object.is(d, deps[i]));
if (equal) return prev.value;
const value = fn();
hookState.slots[id] = { deps: deps.slice(), value };
return value;
}
function useCallback(fn, deps) {
const id = hookState.cursor++;
const prev = hookState.slots[id];
const equal = prev && prev.deps.length === deps.length
&& prev.deps.every((d, i) => Object.is(d, deps[i]));
if (equal) return prev.fn;
hookState.slots[id] = { deps: deps.slice(), fn };
return fn;
}
// Custom-equality memo. Lets you opt into a deep compare for that one case
// where the prop is a small data object you control.
function memo(component, areEqual) {
let last = null;
return function MemoWrapper(props) {
if (last) {
const eq = areEqual ? areEqual(last.props, props) : shallowEqual(last.props, props);
if (eq) return last.result;
}
const result = component(props);
last = { props, result };
return result;
};
}
function shallowEqual(a, b) {
if (a === b) return true;
if (a == null || b == null) return false;
const ak = Object.keys(a);
if (ak.length !== Object.keys(b).length) return false;
for (const k of ak) if (!Object.is(a[k], b[k])) return false;
return true;
}
let renderCount = 0;
function UserCard(props) {
renderCount++;
return 'card[' + props.user.name + '/items=' + props.items.length + '] renders=' + renderCount;
}
const MemoUserCard = memo(UserCard); // default shallow compare
// Fix (a): parent stabilizes references with useMemo + useCallback.
function parentRender(userId) {
beginRender();
const user = useMemo(() => ({ id: userId, name: 'Ada' }), [userId]);
const items = useMemo(() => [1, 2, 3], []);
const onSelect = useCallback(() => { /* ... */ }, []);
return MemoUserCard({ user, onSelect, items });
}
console.log(parentRender(1));
console.log(parentRender(1));
console.log(parentRender(1));
console.log('after fix (a), renders for stable userId:', renderCount);
console.log(parentRender(2));
console.log('after userId changed:', renderCount);
// Fix (b): custom equality. Useful when the user object is reconstructed
// upstream (e.g. fresh from an API response) but the values are equal.
renderCount = 0;
const MemoUserCardDeep = memo(UserCard, (a, b) =>
a.user.id === b.user.id && a.items.length === b.items.length
);
function renderFromApi() {
const user = { id: 1, name: 'Ada' }; // fresh object every time
const items = [1, 2, 3];
const onSelect = () => {};
return MemoUserCardDeep({ user, onSelect, items });
}
console.log(renderFromApi());
console.log(renderFromApi());
console.log(renderFromApi());
console.log('after fix (b), renders despite fresh objects each call:', renderCount);Fix (a) is the one I reach for nine times out of ten. useMemo for the data object, useCallback for the handler, identical references survive across renders, the default shallow compare in memo finally hits, and the child stops re-rendering. The price is that you have to remember the dependency arrays for every memoized prop, and a stale dependency turns into a stuck handler. Fix (b) is the surgical version: when the data legitimately comes in fresh on every render, like a user object hydrated from a fetch response, a custom equality function can compare by id rather than by reference. I keep both in the toolbox, but the ordering matters: prefer to stabilize at the source, fall back to deep-equal only when stabilization would be invasive.
