React.memo and Shallow Comparison
The Gatekeeper Component
React.memo adds a check before rendering: "Are the new props the same as the old props?" If yes, skip the render entirely. If no, render normally.
// Without memo: renders every time Parent renders
function ExpensiveList({ items, onSelect }) {
console.log('ExpensiveList rendered');
return items.map(item => (
<div key={item.id} onClick={() => onSelect(item.id)}>
{heavyComputation(item)}
</div>
));
}
// With memo: only renders when items or onSelect actually change
const MemoizedList = memo(function ExpensiveList({ items, onSelect }) {
console.log('ExpensiveList rendered');
return items.map(item => (
<div key={item.id} onClick={() => onSelect(item.id)}>
{heavyComputation(item)}
</div>
));
});
The Mental Model
Think of React.memo as a security guard at a nightclub door. Without the guard, everyone who walks up gets in (every render). With the guard, they check your ID (props comparison). If your ID matches someone already inside (same props as last render), the guard says "you're already in" and turns you away (skip render).
The guard does a quick glance at each ID field — that's the shallow comparison. They check if the name, photo, and birthdate are the same values. But they don't open your wallet and compare the contents — that would be deep comparison, and the line would be too slow.
If you hand the guard a new ID card with the same information on it, they let you in — because it's a different card (different object reference), even though the data is identical.
How Shallow Comparison Works
React.memo uses Object.is to compare each prop:
// React.memo's comparison (simplified)
function shallowEqual(prevProps, nextProps) {
const prevKeys = Object.keys(prevProps);
const nextKeys = Object.keys(nextProps);
if (prevKeys.length !== nextKeys.length) return false;
for (const key of prevKeys) {
if (!Object.is(prevProps[key], nextProps[key])) {
return false; // Different value → re-render
}
}
return true; // All same → skip render
}
The critical point: shallow comparison checks references, not values. Two objects with identical contents are "different" if they're different instances.
When memo Helps
React.memo is beneficial when ALL of these are true:
- The component re-renders frequently (parent updates often)
- The component's props rarely change (most re-renders are unnecessary)
- The component is expensive to render (heavy computation, large subtree)
// GOOD use of memo: expensive component, stable props, frequent parent renders
const ChartWidget = memo(function ChartWidget({ data, config }) {
// Expensive: parses data, computes SVG paths, renders hundreds of elements
const paths = computeChartPaths(data); // Heavy
return (
<svg>
{paths.map(p => <path key={p.id} d={p.d} stroke={p.color} />)}
</svg>
);
});
function Dashboard() {
const [time, setTime] = useState(Date.now());
const chartData = useChartData(); // Stable reference (from external store)
useEffect(() => {
const id = setInterval(() => setTime(Date.now()), 1000);
return () => clearInterval(id);
}, []);
return (
<>
<Clock time={time} /> {/* Updates every second */}
<ChartWidget data={chartData} config={chartConfig} /> {/* memo skips re-render */}
</>
);
}
Without memo, ChartWidget re-renders every second (because Dashboard re-renders for the clock). With memo, it only re-renders when chartData or chartConfig changes.
When memo Doesn't Help (or Hurts)
// WASTEFUL: props change every render anyway
const UserCard = memo(function UserCard({ user, onClick }) {
return <div onClick={onClick}>{user.name}</div>;
});
function UserList({ users }) {
return users.map(user => (
<UserCard
key={user.id}
user={user}
onClick={() => selectUser(user.id)} // New function every render!
/>
));
}
The onClick prop is a new function reference every render. Memo compares it: Object.is(oldFn, newFn) → false. The component re-renders anyway. The memo wrapper added overhead (the comparison) with zero benefit.
Common patterns that defeat memo:
// 1. Inline objects
<Child style={{ color: 'red' }} /> // New object every render
// 2. Inline functions
<Child onClick={() => handleClick(id)} /> // New function every render
// 3. Spread props
<Child {...computeProps()} /> // New object every render
// 4. Children as props
<Child><GrandChild /></Child> // Children are a new React element every renderAll of these create new references on every render, causing memo to always re-render. To fix them, stabilize references with useMemo, useCallback, or restructuring.
Custom Comparison Functions
React.memo accepts a custom comparison function:
const UserAvatar = memo(
function UserAvatar({ user, size }) {
return <img src={user.avatarUrl} width={size} height={size} alt={user.name} />;
},
// Custom comparison: only re-render if avatarUrl or size changed
(prevProps, nextProps) => {
return (
prevProps.user.avatarUrl === nextProps.user.avatarUrl &&
prevProps.size === nextProps.size
);
}
);
The custom comparator returns true to SKIP rendering (props are the same) and false to RE-RENDER (props differ). Note: this is the opposite of shouldComponentUpdate.
Custom comparison functions are a maintenance burden. They must be updated whenever the component's props change. If you add a new prop and forget to update the comparator, the component won't respond to changes in that prop. Use custom comparators only when the shallow default genuinely can't work (e.g., deep object props where only specific nested values matter).
Production Scenario: memo That Made Things Worse
A team wraps every component in React.memo "for performance." The result:
// Team adds memo to EVERY component
const Button = memo(function Button({ children, onClick, variant }) { ... });
const Text = memo(function Text({ children, size, color }) { ... });
const Icon = memo(function Icon({ name, size }) { ... });
const Flex = memo(function Flex({ children, gap, direction }) { ... });
// Usage in a form — EVERY prop is new every render
function LoginForm() {
const [email, setEmail] = useState('');
return (
<Flex direction="column" gap={16}> {/* children change → re-render anyway */}
<Text size="lg" color="primary">Login</Text> {/* No savings — just overhead */}
<input value={email} onChange={e => setEmail(e.target.value)} />
<Button
onClick={() => login(email)} {/* New function → re-render */}
variant="primary"
>
<Icon name="login" size={16} /> {/* children change → re-render */}
Submit
</Button>
</Flex>
);
}
Every component has memo, but almost every prop is a new reference each render (inline functions, children elements, inline objects). Memo checks props (cost), finds they're different (always), and renders anyway (same cost as without memo). Net result: slower than no memo, because the comparison overhead adds up.
The fix: remove memo from small/cheap components. Add it only to demonstrably expensive ones after profiling.
Common Mistakes
| What developers do | What they should do |
|---|---|
| Wrapping every component in React.memo as a blanket optimization Memo adds overhead (prop comparison on every render). If props usually change, memo costs more than it saves | Profile first. Only memo components that are expensive AND receive stable props |
| Using memo without stabilizing object/function props Inline objects and functions create new references every render, making memo's comparison always fail | Use useMemo for objects and useCallback for functions passed to memoized children |
| Assuming memo makes a component faster to render Memo only adds a comparison gate. When props DO change, the component renders normally plus the comparison overhead | memo prevents unnecessary renders. When it does render, it's exactly as fast (or slightly slower) as without memo |
| Using memo on components that receive children `<Parent>``<Child />``</Parent>` creates a new React element for Child on every Parent render. children prop fails shallow comparison | Children (JSX elements) are new objects every render. Memo can't help unless children are memoized too |
Challenge
Challenge: Will memo prevent the re-render?
const ExpensiveChart = memo(function ExpensiveChart({ data, config, onHover }) {
// ... renders a complex SVG chart
});
function Dashboard() {
const [count, setCount] = useState(0);
const data = useMemo(() => processData(rawData), [rawData]);
const config = { theme: 'dark', animate: true };
return (
<>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveChart
data=`{data}`
config=`{config}`
onHover={(point) => console.log(point)}
/>
</>
);
}
// When the user clicks the button, does ExpensiveChart re-render?
// If yes, which prop(s) cause it? How would you fix it?
Show Answer
Yes, ExpensiveChart re-renders despite memo. Two props defeat it:
-
config—{ theme: 'dark', animate: true }is a new object every render.Object.is(oldConfig, newConfig)→false. -
onHover—(point) => console.log(point)is a new function every render.Object.is(oldFn, newFn)→false.
data is fine — useMemo stabilizes the reference when rawData hasn't changed.
Fix:
function Dashboard() {
const [count, setCount] = useState(0);
const data = useMemo(() => processData(rawData), [rawData]);
const config = useMemo(() => ({ theme: 'dark', animate: true }), []);
const onHover = useCallback((point) => console.log(point), []);
return (
<>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveChart data={data} config={config} onHover={onHover} />
</>
);
}With the React Compiler (React 19+), these manual memoizations become unnecessary — the compiler inserts them automatically.
Quiz
Key Rules
- 1React.memo wraps a component to skip re-renders when props haven't changed (via shallow Object.is comparison).
- 2Shallow comparison checks references, not values.
{a:1}!=={a:1}because they're different objects in memory. - 3memo is only worthwhile when the component is expensive AND its props are mostly stable. Profile before adding memo.
- 4Inline objects, arrays, and functions create new references every render, defeating memo. Stabilize them with useMemo and useCallback.
- 5Never memo every component by default. The comparison overhead on cheap components with changing props makes things slower.
- 6With React 19 Compiler enabled, manual memo/useMemo/useCallback become unnecessary — the compiler handles memoization automatically.