We've all been there. You're reviewing a PR and you see useMemo wrapping a function that returns a static string. Or useCallback on a handler that's not passed anywhere as a prop. You ask why, and the answer is always some variation of 'for performance'. It's not performance. It's cargo culting. And we did it too, for longer than we'd like to admit.
React performance is one of those topics where the tooling is visible enough that it feels like you should be using it constantly, but the actual bottlenecks are invisible until you measure. This post is about developing the instinct to tell the difference — when useMemo and useCallback genuinely help, and when they're just noise that makes your code harder to read.
First, Understand What These Hooks Actually Do
useMemo caches the result of a computation between renders. useCallback caches a function reference between renders. That's it. The cache is keyed on a dependency array — if nothing in that array changes, you get back the same value/function from the previous render.
The cost of NOT using them is that React re-runs the computation or creates a new function reference on every render. The cost of USING them is that React has to store the cached value, compare the dependency array on every render, and manage that memory. Neither cost is free.
// This is wasteful — useMemo has overhead, and this computation
// is so trivial that re-running it is almost certainly cheaper
const fullName = useMemo(() => {
return `${firstName} ${lastName}`;
}, [firstName, lastName]);
// Just do this
const fullName = `${firstName} ${lastName}`;
// THIS is a candidate for useMemo — expensive computation,
// probably running on every keystroke if in a form
const filteredAndSortedResults = useMemo(() => {
return largeDataset
.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()))
.sort((a, b) => a.score - b.score)
.slice(0, 50);
}, [largeDataset, searchQuery]);The gap between these two examples is everything. One is string concatenation. The other is potentially filtering and sorting thousands of objects on every keystroke. Using useMemo on the first makes your code harder to read for zero gain. NOT using it on the second makes your UI feel sluggish.
The Three Actual Use Cases for useMemo
After building enough production React apps, we've narrowed useMemo down to three situations where it genuinely earns its keep:
- Expensive computations that run on every render — filtering large arrays, complex calculations, building derived data from raw inputs
- Referential stability for objects/arrays passed as props to memoized children — when you have React.memo on a child component and you're passing it an object literal, useMemo prevents unnecessary re-renders
- Dependencies in other hooks — when you need a stable object reference as a dependency of useEffect or another useMemo, to avoid infinite loops
Notice what's NOT on the list: 'I just want to be safe', 'the component renders a lot', or 'someone on Twitter said to memoize everything'. Those aren't reasons. Measure first, optimize second.
useCallback Is the Same Story, Different Chapter
useCallback is useMemo but specifically for functions. Same trade-offs apply. The main legitimate use case is passing callbacks to memoized child components — if you're not doing that, useCallback is probably just adding noise.
// Pointless — this component isn't memoized, so useCallback
// does nothing useful here
function SearchForm({ onSearch }: { onSearch: (query: string) => void }) {
const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
onSearch(inputValue);
}, [inputValue, onSearch]);
return <form onSubmit={handleSubmit}>...</form>;
}
// Legitimate — ExpensiveList is memoized with React.memo,
// so stable function references prevent unnecessary re-renders
const Parent = () => {
const [count, setCount] = useState(0);
const [query, setQuery] = useState('');
const handleItemClick = useCallback((id: string) => {
// do something with id
}, []); // no dependencies = perfectly stable
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* This won't re-render when count changes because handleItemClick is stable */}
<ExpensiveList onItemClick={handleItemClick} query={query} />
</div>
);
};
const ExpensiveList = React.memo(({ onItemClick, query }: Props) => {
// Expensive rendering logic...
return <div>...</div>;
});The pattern only works if the child is wrapped in React.memo. useCallback without React.memo on the child is like buying a lock with no door. The function reference is stable, but the child re-renders anyway because React doesn't care about referential equality by default.
How to Actually Find Real Performance Problems
Before you reach for useMemo, open the React DevTools Profiler and record a slow interaction. This tool will show you exactly which components are re-rendering, how long they're taking, and why they re-rendered. Spent 30 minutes in this tool once and you'll develop an entirely different intuition for where React performance problems actually live.
Usually what you find is one of three things: a massive component at the top of the tree that holds unrelated state, an expensive list that re-renders when it shouldn't, or a calculation inside a render that's doing more work than you realized. The fix is rarely 'sprinkle useMemo everywhere'. It's more often 'move this state down' or 'split this component'.
// Common culprit: giant parent holding unrelated state
// Every button click re-renders EVERYTHING below
function Dashboard() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [notifications, setNotifications] = useState([]);
const [userData, setUserData] = useState(null);
return (
<div>
<Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen(o => !o)} />
<ExpensiveDataGrid data={userData} /> {/* re-renders on sidebar toggle */}
<NotificationBell count={notifications.length} />
</div>
);
}
// Better: isolate the state so only what needs to re-render does
function SidebarToggle() {
const [open, setOpen] = useState(false);
return <Sidebar open={open} onToggle={() => setOpen(o => !o)} />;
}
function Dashboard() {
const [notifications, setNotifications] = useState([]);
const [userData, setUserData] = useState(null);
return (
<div>
<SidebarToggle /> {/* its own state, doesn't affect siblings */}
<ExpensiveDataGrid data={userData} /> {/* no longer re-renders on sidebar toggle */}
<NotificationBell count={notifications.length} />
</div>
);
}Before you memoize, try restructuring. Moving state down or splitting components often eliminates the re-render entirely — no caching needed, no dependency arrays to maintain.
The Dependency Array Is a Footgun in Disguise
Here's a thing people underestimate about useMemo and useCallback: the dependency array is easy to get wrong and hard to debug when you do. Miss a dependency and you get stale data or stale closures. Add too many and your 'optimization' re-runs on every render anyway, giving you all the complexity with none of the benefit.
The eslint-plugin-react-hooks exhaustive-deps rule helps, but it's not perfect. Objects and functions defined inline will always show up as 'changed' between renders, which is why you sometimes need useMemo to stabilize them before they can be deps in other hooks. This is the one case where useMemo cascades legitimately — you're not optimizing render speed, you're preventing infinite loops in useEffect.
// Classic infinite loop: options is a new object every render,
// so useEffect sees a 'changed' dep and runs again, which triggers
// a state update, which causes a render, which creates a new options...
function BadComponent({ userId }: { userId: string }) {
const options = { userId, includeDeleted: false }; // new reference every render
useEffect(() => {
fetchUserData(options).then(setData);
}, [options]); // infinite loop
}
// Fix: stabilize the object with useMemo
function GoodComponent({ userId }: { userId: string }) {
const options = useMemo(
() => ({ userId, includeDeleted: false }),
[userId] // only rebuilds when userId actually changes
);
useEffect(() => {
fetchUserData(options).then(setData);
}, [options]); // now stable
}
// Even better: just use the primitive directly
function BestComponent({ userId }: { userId: string }) {
useEffect(() => {
fetchUserData({ userId, includeDeleted: false }).then(setData);
}, [userId]); // primitives compare by value, no issues
}When possible, depend on primitives (strings, numbers, booleans) rather than objects. It sidesteps the whole referential equality problem and you never need useMemo to stabilize them.
A Practical Decision Framework
When you're writing code and the thought 'should I useMemo this?' crosses your mind, run through this:
- Is this computation actually expensive? If you can't describe why in one sentence, it probably isn't. String formatting, simple math, array access — don't memoize.
- Is this value used as a prop to a React.memo component? If yes, consider it. If no, probably not worth it.
- Is this an object/array being passed as a useEffect dependency? Then memoize it or refactor to use primitives.
- Have you measured and confirmed this is actually slow? If not, write the simple version first.
The default answer should be 'no, don't memoize'. Reach for it when you have evidence, not anxiety.
This is the same philosophy we bake into the templates at peal.dev — components are written without defensive memoization scattered everywhere. When you need it, it's obvious from context. When you don't, the code stays readable.
What Actually Makes React Fast in Practice
After shipping enough production apps, the performance wins rarely came from useMemo. They came from:
- Virtualization for long lists — react-window or TanStack Virtual. Rendering 10,000 DOM nodes is slow. Rendering 20 at a time is fast.
- Code splitting with dynamic imports — don't ship 500kb of JavaScript for a settings page that 5% of users visit
- Moving to server components where possible — data that doesn't need to be interactive shouldn't be fetching on the client
- Debouncing expensive operations — don't run a filter on 10k items on every keystroke, wait 150ms
- Getting state management right — Zustand or Jotai for global state, local state for component-specific concerns, and not putting everything in Context
These changes consistently produce larger wins than memoization. They're also usually more permanent — once you virtualize a list, you're done. useMemo requires maintenance every time dependencies change.
The best React performance optimization is deleting code and components, not adding memoization. If a component doesn't exist, it can't be slow.
Here's the honest summary: useMemo and useCallback are real tools that solve real problems in specific situations. They're not general-purpose performance switches you flip on everything that moves. Use the React Profiler to find actual bottlenecks, reach for structural solutions before caching solutions, and when you do memoize — make sure the reason is concrete enough that you can write it in a comment. If you can't explain why, delete it. Your future self will thank you.
