React.memo and Referential Equality

React.memo does shallow comparison — which means object props break it in a way that's easy to miss.

March 22, 20264 min read2 / 5

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.

TSX
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:

TSX
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.

JavaScript
{ a: 1 } === { a: 1 } // false — different objects in memory [1, 2, 3] === [1, 2, 3] // false — different arrays in memory

Two 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:

TSX
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.

TSX
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:

TSX
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:

TSX
// ❌ 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.

TSX
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.

Stable Object Props with useMemo
1 exercise

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.