React.memo and Referential Equality
React.memo does shallow comparison — which means object props break it in a way that's easy to miss.
React.memo is straightforward in theory: wrap a component and it only re-renders when props change. In practice, there's one behaviour that bites almost everyone — referential equality.
How React.memo Works
memo is a higher-order component. It wraps a component function and performs a shallow equality check on props before deciding whether to re-render.
import { memo } from 'react';
const UserCard = memo(function UserCard({ name, age }) {
return <div>{name}, {age}</div>;
});On every render of the parent, React compares the new props with the previous ones using ===. If all props are strictly equal, the component is skipped entirely.
For primitive props, this works as expected:
function App() {
const [count, setCount] = useState(0);
// name and age are primitives — React.memo comparison: "Alice" === "Alice" ✓
return <UserCard name="Alice" age={30} />;
}Every time App re-renders, UserCard receives "Alice" and 30. Those are the same values and the same references — so UserCard is skipped.
The Referential Equality Problem
JavaScript compares objects and arrays by reference, not by value.
{ a: 1 } === { a: 1 } // false — different objects in memory
[1, 2, 3] === [1, 2, 3] // false — different arrays in memoryTwo objects with identical content are not equal unless they are the same object. This breaks React.memo when props are objects or arrays created inline:
function App() {
const [count, setCount] = useState(0);
// ❌ New object reference on every render of App
const style = { color: 'blue', fontSize: 16 };
return <UserCard name="Alice" style={style} />;
}Every render of App creates a new style object. The content is the same — { color: 'blue', fontSize: 16 } — but it's a different object in memory. React's === check returns false, and UserCard re-renders even though nothing meaningful changed.
The same applies to arrays, functions (covered in Memoization and useCallback), and object literals passed as props.
The Fix: useMemo for Object Props
useMemo memoizes a computed value — in this case, the object itself.
function App() {
const [count, setCount] = useState(0);
// ✅ Same object reference until the memoized values change
const style = useMemo(
() => ({ color: 'blue', fontSize: 16 }),
[] // no dependencies — never changes
);
return <UserCard name="Alice" style={style} />;
}Now style is the same object reference across renders. React.memo sees no change in the style prop — UserCard is skipped.
When the object depends on state or props:
function App({ theme, baseFontSize }) {
const [count, setCount] = useState(0);
const style = useMemo(
() => ({ color: theme === 'dark' ? 'white' : 'black', fontSize: baseFontSize }),
[theme, baseFontSize] // recreate only when these change
);
return <UserCard name="Alice" style={style} />;
}The Simpler Fix: Pass Primitives
Before reaching for useMemo, consider whether you can just pass primitives instead of objects:
// ❌ Object prop — breaks React.memo
<UserCard style={{ color: 'blue', fontSize: 16 }} />
// ✅ Primitive props — React.memo works naturally
<UserCard color="blue" fontSize={16} />Primitives compare by value with ===, so React.memo handles them correctly without any memoization hooks. This is usually the cleaner solution when the component's API allows it.
A Custom Comparison Function
memo accepts an optional second argument: a custom comparison function.
const UserCard = memo(
function UserCard({ user }) { /* ... */ },
(prevProps, nextProps) => {
// Return true to SKIP re-render (props are "equal")
// Return false to ALLOW re-render (props have changed)
return prevProps.user.id === nextProps.user.id;
}
);The semantics are the inverse of what you'd expect from a standard equality check — returning true means "same, don't re-render." This is a common source of bugs when you first encounter it.
A custom comparator is useful when you have a deeply nested object and you only care about a specific field. But for most cases, either fixing the prop types (primitives) or memoizing the object is the right solution.
The Children Prop Exception
One pattern that sidesteps this problem entirely: passing expensive components as children rather than as regular props.
Components passed as children are created by the parent of the wrapper — not by the wrapper itself. So when the wrapper re-renders, the children elements have the same React element reference, and React skips them automatically — no memo needed.
Anatomy of a Re-render explains this mechanism in detail. It's often a cleaner option than wiring up memo + useMemo on every object prop.
When React.memo Is the Wrong Tool
React.memo is not a default — it's a targeted optimization for specific pain points. There are two traps to avoid:
Wrapping everything in memo: Shallow comparison runs on every render. For cheap components, that comparison can cost more than just letting the component re-render.
Expecting memo to fix logic bugs: If a component has an expensive computation in its render body, memo won't fix that. The computation still runs when the component renders. The right tool is useMemo for the computation itself.
memo stops unnecessary renders. It doesn't speed up the renders that do happen.
Practice what you just read.
Keep reading
Enjoyed this? Get more like it.
Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.