React.memo & Referential Equality
The Default: Everything Re-Renders
This surprises almost every React developer the first time they hear it. When a parent component re-renders, React re-renders all of its children. Not because props changed — because React does not know whether props changed without actually rendering.
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveChart data={staticData} /> {/* Re-renders on every click */}
<Footer /> {/* Re-renders too */}
</div>
);
}
Every time count changes, Parent re-renders. React then calls ExpensiveChart and Footer even though their props have not changed. Why? Because React's reconciliation is top-down. When Parent returns new JSX, React must call each child component to produce its output, then diff that output against the previous render. Skipping the call entirely requires an explicit opt-in.
React's default behavior is like a manager who asks every team member for a status update every morning, even if nothing has changed. React.memo is the team member saying "nothing changed since yesterday" and the manager trusting that without checking. The manager saves time — but only if the team member's check ("did anything change?") is cheaper than the full status report.
Object.is: React's Equality Check
Before we go further, you need to understand the one function that drives all of React's comparison logic. React uses Object.is() for all comparisons — props in React.memo, dependency arrays in hooks, state updates in useState.
Object.is(1, 1); // true — same primitive
Object.is('a', 'a'); // true — same string
Object.is(true, true); // true
Object.is({}, {}); // false — different object references
Object.is([], []); // false — different array references
Object.is(fn, fn); // true only if fn is the exact same reference
Object.is(NaN, NaN); // true (unlike ===)
Object.is(0, -0); // false (unlike ===)
The critical rule: objects, arrays, and functions are compared by reference, not by value. Two objects with identical contents are not equal unless they are literally the same object in memory.
React.memo: Shallow Prop Comparison
React.memo wraps a component and skips re-rendering if all props pass a shallow Object.is comparison.
const ExpensiveChart = React.memo(function ExpensiveChart({ data, color }) {
// Expensive rendering logic
return <canvas>{/* ... */}</canvas>;
});
function Parent() {
const [count, setCount] = useState(0);
const data = useMemo(() => processData(rawData), [rawData]);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveChart data={data} color="blue" />
</div>
);
}
When count changes and Parent re-renders, React reaches ExpensiveChart and checks:
Object.is(prevProps.data, nextProps.data)— same reference? (Yes,useMemopreserved it)Object.is(prevProps.color, nextProps.color)—"blue" === "blue"? (Yes, string primitive)
Both pass. React skips calling ExpensiveChart entirely. No function call, no JSX diff, no DOM work for that subtree.
When memo Helps
React.memo pays off when:
- The component is expensive to render — complex calculations, large DOM trees, heavy SVG charts
- The component re-renders frequently with the same props — parent state changes often but this child's props stay stable
- Props are referentially stable — primitives, or objects/functions stabilized with
useMemo/useCallback
When memo Hurts
React.memo has a cost — the shallow comparison runs on every parent render. If the component always receives new props, memo runs the comparison and then renders anyway. That is strictly worse than just rendering.
// BAD: memo is wasted — style is a new object every render
function Parent() {
return <MemoizedBox style={{ padding: 16, margin: 8 }} />;
}
// BAD: memo is wasted — onClick is a new function every render
function Parent() {
return <MemoizedButton onClick={() => doSomething()} />;
}
The Referential Equality Trap
This is the part that silently breaks your optimizations. The most common mistake with React.memo is passing new object or function references as props without realizing it.
Inline Object Literals
// Every render creates a new { padding: 16 } object
<MemoizedCard style={{ padding: 16 }} />
// Fix: define outside component or useMemo
const cardStyle = { padding: 16 }; // Module-level constant
function Parent() {
return <MemoizedCard style={cardStyle} />;
}
Inline Function Expressions
// Every render creates a new function
<MemoizedList onItemClick={(id) => selectItem(id)} />
// Fix: useCallback
function Parent() {
const handleItemClick = useCallback((id) => {
selectItem(id);
}, []);
return <MemoizedList onItemClick={handleItemClick} />;
}
Inline Array Literals
// Every render creates a new array — even though contents are identical
<MemoizedNav items={['home', 'about', 'contact']} />
// Fix: module constant or useMemo
const navItems = ['home', 'about', 'contact'];
function Parent() {
return <MemoizedNav items={navItems} />;
}
Computed Objects in Render
// filter() always returns a new array reference
function Parent({ items }) {
const filtered = items.filter(i => i.active); // New array every render
return <MemoizedList items={filtered} />;
}
// Fix: useMemo to stabilize the reference
function Parent({ items }) {
const filtered = useMemo(() => items.filter(i => i.active), [items]);
return <MemoizedList items={filtered} />;
}
The children prop is an object too. This silently breaks memo:
// children is a new React element object every render
<MemoizedPanel>
<p>Hello</p>
</MemoizedPanel>
// Even this breaks memo:
<MemoizedPanel children={<p>Hello</p>} /><p>Hello</p> compiles to React.createElement('p', null, 'Hello'), which returns a new object every call. If MemoizedPanel receives children, the memo comparison fails every time. Solutions: avoid passing children to memo'd components, or use a custom comparison function that ignores children.
Custom Comparison Functions
What if shallow comparison isn't enough? React.memo accepts an optional comparison function for cases where you need more control:
const UserCard = React.memo(
function UserCard({ user, onEdit }) {
return (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
<button onClick={onEdit}>Edit</button>
</div>
);
},
(prevProps, nextProps) => {
// Return true to SKIP render, false to RE-RENDER
// Only re-render if the user data actually changed
return (
prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name &&
prevProps.user.email === nextProps.user.email
// Intentionally ignore onEdit — we trust it does the same thing
);
}
);
Every new prop you add to a component with a custom comparison must also be added to the comparison function. If you forget, the component silently ignores the new prop. Prefer stabilizing prop references (with useMemo/useCallback) over custom comparisons. Use custom comparisons only when you cannot control the prop references (e.g., third-party data sources that create new objects on every fetch).
React.memo vs Component Structure
Here's the thing most people miss: before reaching for React.memo, consider whether restructuring your components solves the problem entirely:
// Problem: ExpensiveTree re-renders on every keystroke
function App() {
const [text, setText] = useState('');
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<ExpensiveTree />
</div>
);
}
// Solution 1: React.memo
const ExpensiveTree = React.memo(function ExpensiveTree() {
return /* expensive JSX */;
});
// Solution 2: Lift state into its own component (no memo needed)
function App() {
return (
<div>
<SearchInput />
<ExpensiveTree />
</div>
);
}
function SearchInput() {
const [text, setText] = useState('');
return <input value={text} onChange={e => setText(e.target.value)} />;
}
Solution 2 is often better. Moving state down (or content up) means the re-render boundary naturally excludes expensive siblings. No memoization needed, no referential equality to manage.
The React Compiler Future
So will you need to think about all of this forever? Not necessarily. React Compiler (formerly React Forget) automatically injects memoization at the compiler level. It analyzes your component code, detects which values need to be stable, and wraps them in memoization primitives — essentially auto-generating useMemo and useCallback where beneficial.
// You write this:
function TodoList({ todos, onToggle }) {
const visible = todos.filter(t => !t.hidden);
return visible.map(t => <Todo key={t.id} todo={t} onToggle={onToggle} />);
}
// Compiler outputs something like:
function TodoList({ todos, onToggle }) {
const visible = useMemo(() => todos.filter(t => !t.hidden), [todos]);
return useMemo(
() => visible.map(t => <Todo key={t.id} todo={t} onToggle={onToggle} />),
[visible, onToggle]
);
}
This does not make manual understanding unnecessary. The compiler can only memoize what is safe to memoize. Understanding referential equality helps you write code that the compiler can optimize well, and debug cases where it cannot.
- 1React re-renders all children when a parent re-renders. This is by design — React cannot know if output changed without calling the component.
- 2React.memo opts into shallow prop comparison via Object.is. It skips rendering only if ALL props have the same reference.
- 3Objects, arrays, and functions are compared by reference. Two identical-looking objects created separately are not equal.
- 4Inline object/array/function literals in JSX break React.memo — they create new references every render.
- 5Custom comparison functions are dangerous — every prop affecting output must be compared, or you get stale renders.
- 6Before using React.memo, try restructuring components: move state down or lift content up to avoid the re-render entirely.
- 7React Compiler will auto-insert memoization, but understanding referential equality remains essential for debugging and edge cases.
Q: When would you NOT use React.memo, and why?
A strong answer covers: when the component is cheap to render (comparison cost exceeds render cost), when props change on every render (new objects/functions as props — memo runs comparison then renders anyway), when the component always re-renders due to children prop (JSX children are new objects every render), and the alternative of restructuring the component tree to avoid the re-render entirely. Bonus: mention that React Compiler is making manual memo increasingly unnecessary.