The memo + useCallback Trap
The Reflexive Pattern
A developer profiles their app, sees unnecessary re-renders, and reaches for the standard fix:
// Step 1: Wrap in memo
const TodoItem = memo(function TodoItem({ todo, onToggle, onDelete }) {
return (
<li>
<input type="checkbox" checked={todo.completed} onChange={() => onToggle(todo.id)} />
{todo.text}
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
);
});
// Step 2: Stabilize callbacks with useCallback
function TodoList({ todos }) {
const onToggle = useCallback((id) => {
setTodos(prev => prev.map(t => t.id === id ? { ...t, completed: !t.completed } : t));
}, []);
const onDelete = useCallback((id) => {
setTodos(prev => prev.filter(t => t.id !== id));
}, []);
return todos.map(t => (
<TodoItem key={t.id} todo={t} onToggle={onToggle} onDelete={onDelete} />
));
}
This looks like the "correct" React performance pattern. Sometimes it is. Often it's unnecessary overhead.
The Mental Model
Think of the memo + useCallback pattern as gift wrapping each present before putting them in a gift bag, then checking each present's wrapping at the door. If you're handing out 500 presents at a party (rendering 500 list items), the wrapping (useCallback) and checking (memo) saves time because most presents are unchanged.
But if you're handing out 3 presents, the time spent wrapping and checking is more than just handing them over. The overhead isn't worth it for small, cheap components.
The question isn't "is this pattern correct?" — it always "works." The question is "does the optimization save more time than it costs?"
When This Pattern Is Necessary
The memo + useCallback combination is necessary when:
- Long lists: Rendering 100+ items where each item is non-trivial
- Expensive children: Each child does significant work (SVG rendering, heavy calculations)
- Frequent parent re-renders: Parent state changes often (typing, animations, real-time data)
// JUSTIFIED: 1000 items, parent updates on every keystroke
function SearchableList() {
const [query, setQuery] = useState('');
const [items] = useState(() => generateItems(1000));
const handleItemClick = useCallback((id) => {
navigate(`/items/${id}`);
}, []);
const filtered = useMemo(
() => items.filter(i => i.name.includes(query)),
[items, query]
);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
{filtered.map(item => (
<ItemCard key={item.id} item={item} onClick={handleItemClick} />
))}
</>
);
}
const ItemCard = memo(function ItemCard({ item, onClick }) {
// Non-trivial render: formats dates, calculates prices, renders badges
return (
<div onClick={() => onClick(item.id)}>
<h3>{item.name}</h3>
<PriceBadge price={item.price} />
<DateLabel date={item.createdAt} />
</div>
);
});
Here, every keystroke re-renders SearchableList. Without memo + useCallback, all 1000 ItemCard components re-render on every keystroke — even items not affected by the filter. With memo, only items whose item prop reference changed re-render.
When This Pattern Is Wasteful
// WASTEFUL: 3 buttons, cheap renders, parent rarely updates
function Toolbar() {
const [mode, setMode] = useState('edit');
const handleSave = useCallback(() => saveDocument(), []);
const handleExport = useCallback(() => exportDocument(), []);
const handleShare = useCallback(() => shareDocument(), []);
return (
<div>
<ToolButton onClick={handleSave} icon="save" label="Save" />
<ToolButton onClick={handleExport} icon="export" label="Export" />
<ToolButton onClick={handleShare} icon="share" label="Share" />
</div>
);
}
const ToolButton = memo(function ToolButton({ onClick, icon, label }) {
return <button onClick={onClick}><Icon name={icon} /> {label}</button>;
});
Three buttons. Each renders a button with an icon and text. The total render time for all three is ~0.1ms. The overhead of three useCallback calls + three memo comparisons approaches the cost of just rendering the buttons. This is optimization theater.
useCallback doesn't prevent function creation. It caches the function reference for identity stability, but a new function is still created every render — it's just thrown away if deps haven't changed.
// Common misconception: "useCallback prevents creating the function"
const fn = useCallback(() => doSomething(a, b), [a, b]);
// Reality: () => doSomething(a, b) is created EVERY render
// useCallback just returns the old one if [a, b] haven't changedThe cost of creating a closure in JavaScript is essentially zero. useCallback's value is reference stability for downstream memo comparisons, not avoiding function creation.
The Real Cost Equation
Cost of memo + useCallback:
+ useCallback: Stores function in hook state, compares deps each render
+ memo: Shallow-compares all props before each render
+ Cognitive cost: More code, harder to read, deps arrays to maintain
Cost of just re-rendering:
+ Call component function
+ Create JSX elements (cheap objects)
+ Reconciliation diff (fast for small trees)
- No DOM mutations if output is the same
Break-even: memo saves time only when skipped renders are expensive enough
to outweigh the comparison overhead across ALL renders (including ones where
props DO change and memo lets the render through anyway)
The useCallback-Without-memo Mistake
This is the most common misuse:
function Parent() {
// useCallback without memo on Child is POINTLESS
const handleClick = useCallback(() => {
doSomething();
}, []);
// Child is NOT wrapped in memo — it re-renders when Parent renders
// regardless of whether handleClick reference is stable
return <Child onClick={handleClick} />;
}
function Child({ onClick }) {
return <button onClick={onClick}>Click</button>;
}
useCallback stabilizes the function reference. But if Child isn't memoized, it re-renders whenever Parent renders anyway — the stable reference is never checked. useCallback without a memoized consumer is pure overhead.
Better Alternatives
Before reaching for memo + useCallback, consider these patterns that avoid the problem entirely:
1. State Colocation
// Instead of memo, move state closer
function App() {
return (
<>
<SearchSection /> {/* Manages own state */}
<ExpensiveContent /> {/* Never re-renders from search */}
</>
);
}
function SearchSection() {
const [query, setQuery] = useState('');
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
2. Children as Props (Composition)
// Instead of memo, pass expensive children from above
function ExpandingPanel({ children }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children} {/* children are created by PARENT, not here */}
</div>
);
}
// Usage: ExpensiveContent is created by App, not by ExpandingPanel
// When ExpandingPanel re-renders (isOpen changes), ExpensiveContent
// is the same JSX reference — React skips reconciliation
function App() {
return (
<ExpandingPanel>
<ExpensiveContent />
</ExpandingPanel>
);
}
Production Scenario: The Optimization That Backfired
A team profiles their app and sees a list component re-rendering on every keystroke. They add memo + useCallback:
const ListItem = memo(function ListItem({ item, onEdit, onDelete, isSelected }) {
return (
<div className={isSelected ? 'selected' : ''}>
<span>{item.name}</span>
<button onClick={() => onEdit(item.id)}>Edit</button>
<button onClick={() => onDelete(item.id)}>Delete</button>
</div>
);
});
function ItemList({ items, selectedId, onEdit, onDelete }) {
const stableOnEdit = useCallback(onEdit, [onEdit]);
const stableOnDelete = useCallback(onDelete, [onDelete]);
return items.map(item => (
<ListItem
key={item.id}
item={item}
onEdit={stableOnEdit}
onDelete={stableOnDelete}
isSelected={item.id === selectedId}
/>
));
}
When the user selects a different item, selectedId changes. isSelected changes for exactly two items (old selected and new selected). Memo correctly skips re-rendering the other 98 items.
But then they add a feature: the list items get a lastUpdated timestamp from a WebSocket. Now items array changes every second. Every item gets a new object reference (because the array is recreated). Memo compares item prop: Object.is(oldItem, newItem) → false for every item. All 100 items re-render plus memo comparison overhead. The memo optimization now makes things slower.
The fix: normalize the data so item objects maintain reference equality when only the timestamp changes, or use a custom comparator that ignores lastUpdated.
Common Mistakes
| What developers do | What they should do |
|---|---|
| Using useCallback on every function 'just in case' useCallback without a memoized consumer does nothing. It stabilizes references that nobody checks | Use useCallback only for functions passed to memoized children or used in effect deps |
| Adding memo to a component without checking if props are stable memo compares ALL props. If any single prop is a new reference, the component re-renders — plus you paid for the comparison | Verify all props are reference-stable before adding memo. One unstable prop defeats the entire memo |
| Treating memo + useCallback as a default pattern for all components Most components are cheap enough that the overhead of memo exceeds the cost of just rendering | Use only after profiling shows measurable improvement. Prefer composition and colocation first |
| Wrapping useCallback with empty deps and capturing stale values useCallback(fn, []) captures the initial closure. If fn reads state or props, they'll be stale. This creates subtle bugs that are worse than a few extra renders | Include all values used inside the callback in the dependency array |
Challenge
Challenge: Remove unnecessary memoization
// This component uses memo and useCallback everywhere.
// Identify which ones are unnecessary and remove them.
// Explain why each removal is safe.
const Header = memo(function Header({ title }) {
return <h1>`{title}`</h1>;
});
const StatusBadge = memo(function StatusBadge({ status }) {
return <span className=`{status}`>`{status}`</span>;
});
function App() {
const [status, setStatus] = useState('online');
const [messages, setMessages] = useState([]);
const handleStatusChange = useCallback((newStatus) => {
setStatus(newStatus);
}, []);
const handleNewMessage = useCallback((msg) => {
setMessages(prev => [...prev, msg]);
}, []);
return (
<div>
<Header title="Chat App" />
<StatusBadge status={status} />
<MessageList messages={messages} />
<Controls
onStatusChange=`{handleStatusChange}`
onNewMessage=`{handleNewMessage}`
/>
</div>
);
}
Show Answer
Remove these:
-
memo(Header)—Headerrenders an<h1>tag. Rendering cost is near zero.titleis a string literal ("Chat App") that never changes, so memo's comparison runs every time and always passes. The comparison costs more than just rendering. -
memo(StatusBadge)— Renders a<span>. Trivially cheap. Remove memo. -
useCallback(handleStatusChange)— UnlessControlsis wrapped inmemo, this useCallback does nothing. The stable reference is never compared. -
useCallback(handleNewMessage)— Same reasoning as above. IfControlsis not memoized, this is overhead with no benefit.
Keep if: MessageList and Controls are expensive components wrapped in memo. In that case, the useCallback wrappers for their props are justified. But even then, Header and StatusBadge memos should be removed — they're too cheap to optimize.
Simplified version:
function App() {
const [status, setStatus] = useState('online');
const [messages, setMessages] = useState([]);
return (
<div>
<h1>Chat App</h1>
<span className={status}>{status}</span>
<MessageList messages={messages} />
<Controls onStatusChange={setStatus} onNewMessage={msg => setMessages(prev => [...prev, msg])} />
</div>
);
}Quiz
Key Rules
- 1useCallback without React.memo on the consumer is pure overhead. The stable reference is never compared.
- 2memo + useCallback is justified for long lists (100+ items) with non-trivial item components and frequent parent re-renders.
- 3One unstable prop defeats the entire memo. Verify ALL props are reference-stable before adding memo.
- 4useCallback doesn't prevent function creation — it caches the reference for identity stability.
- 5Prefer composition (children as props) and state colocation over memo + useCallback. They avoid the problem instead of patching it.
- 6With React 19 Compiler, manual memo/useCallback/useMemo become unnecessary. The compiler handles memoization decisions automatically.