useMemo, useCallback, and React.memo: When to Memoize
When memoization helps and when it costs you: useMemo for values, useCallback for functions, React.memo for components, with the referential equality rules.
Wrapping things in useMemo and useCallback has become a reflex, and most of the time the reflex is wrong. Memoisation is not free: every hook you add costs a comparison on every render, plus the memory to hold the cached value, plus the dependency array you now have to keep correct. Applied without a reason, it makes code slower and harder to read at the same time.
The three tools solve genuinely different problems, and knowing which one you need starts with knowing what each caches.
The three tools in one table
| Tool | Caches | Reach for it when |
|---|---|---|
useMemo | The result of a calculation | The calculation is genuinely expensive, or its result is a dependency somewhere else |
useCallback | A function definition | The function is passed to a memoised child or listed in a dependency array |
React.memo | A component's rendered output | The component re-renders often with identical props and is expensive to render |
Notice that two of the three "reach for it when" columns mention something other than raw performance. That is the part people miss, and it is where most of the legitimate uses actually live.
useMemo caches a value
useMemo takes a function and a dependency array, runs the function, and returns the same result on later renders until a dependency changes.
import { useMemo, useState } from 'react';
function ProductList({ products }: { products: Product[] }) {
const [query, setQuery] = useState('');
// Only re-sorts when products or query actually change.
const visible = useMemo(() => {
return products
.filter((p) => p.name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.price - b.price);
}, [products, query]);
return <List items={visible} />;
}Whether that useMemo earns its place depends entirely on how many products there are. With 40, the filter and sort take a fraction of a millisecond and the hook is pure overhead. With 40,000, it is the difference between a responsive input and one that stutters on every keystroke.
When to use useMemo: measure before you memoise
You do not have to guess. Wrap the calculation in a timer and type into the input a few times:
console.time('filter+sort');
const visible = products.filter(/* ... */).sort(/* ... */);
console.timeEnd('filter+sort');My rough threshold is 1ms. Under that, the calculation is not what is making your app feel slow, and adding useMemo is cargo cult. Over 5ms and it is worth memoising, because you are eating into the 16ms budget a 60fps frame gives you. Between the two, use judgement about how often the component renders.
The second reason, which matters more
The other use for useMemo has nothing to do with expensive calculations. It is about referential stability: keeping an object or array the same reference across renders so that whatever depends on it does not see a change.
// Without useMemo this object is new on every render, so the effect
// below reconnects constantly even when roomId never changed.
const config = useMemo(
() => ({ serverUrl, roomId }),
[serverUrl, roomId]
);
useEffect(() => {
const connection = createConnection(config);
connection.connect();
return () => connection.disconnect();
}, [config]);This is the same referential identity problem that breaks dependency arrays generally, and I walked through several versions of it in the useEffect dependency array. Worth saying plainly: in that specific example, moving the object inside the effect would have been better than memoising it. Memoisation is the fallback when you cannot restructure.
useCallback caches a function
Functions declared in a component body are recreated on every render. Usually irrelevant. It matters when that function is a dependency of something else, because a new function reference reads as a change.
import { useCallback, useState } from 'react';
function SearchBox({ onResults }: { onResults: (r: Result[]) => void }) {
const [query, setQuery] = useState('');
const search = useCallback(async () => {
const results = await api.search(query);
onResults(results);
}, [query, onResults]);
useEffect(() => {
const id = setTimeout(search, 300);
return () => clearTimeout(id);
}, [search]); // Stable unless query or onResults changes.
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}Without useCallback, search would be a new function on every render, the effect would re-run on every render, and the debounce timer would reset forever. The search would never fire. That is a functional bug, not a performance one, which is the strongest kind of reason to memoise.
useMemo vs useCallback
They are the same hook wearing different hats. useCallback(fn, deps) is exactly useMemo(() => fn, deps). The only difference is that useMemo calls the function you give it and caches the return value, while useCallback caches the function itself.
// These two are equivalent.
const handleClick = useCallback(() => setCount(c => c + 1), []);
const handleClick = useMemo(() => () => setCount(c => c + 1), []);The double arrow in the second line is why useCallback exists. Use it for functions, useMemo for everything else, and do not overthink it.
React.memo caches a component
React.memo wraps a component and skips re-rendering it when its props are shallowly equal to last time. This is the only one of the three that stops React work rather than JavaScript work.
import { memo } from 'react';
type RowProps = {
item: Item;
onSelect: (id: string) => void;
};
// Skips re-rendering when item and onSelect are the same references.
export const Row = memo(function Row({ item, onSelect }: RowProps) {
return (
<li onClick={() => onSelect(item.id)}>
{item.name}
</li>
);
});Shallow equality is the catch. Every prop is compared with Object.is, so one inline object or arrow function in the parent defeats the whole thing:
// Row is memoised, and it re-renders every single time anyway.
// Both of these props are new references on every parent render.
<Row item={item} onSelect={(id) => select(id)} />
<Row item={{ ...item, selected: true }} onSelect={onSelect} />This is the most common way memoisation silently fails. The component is wrapped, the developer assumes it is optimised, and a profiler run months later shows it re-rendering on every keystroke. React DevTools has a "Highlight updates when components render" setting that makes this visible in about ten seconds.
children defeats it too
JSX passed as children is an object, created fresh each render. A memoised component with children re-renders whenever its parent does:
const Panel = memo(function Panel({ children }: { children: ReactNode }) {
return <div className="panel">{children}</div>;
});
function Parent() {
// <Heavy /> is a new element object on every render of Parent,
// so Panel's memo comparison always fails.
return <Panel><Heavy /></Panel>;
}Memoising wrapper components is rarely worth it for this reason. Memoise the leaves that are expensive, not the containers.
The custom comparison function
React.memo takes an optional second argument: a function that receives the previous and next props and returns true if they should be treated as equal. It looks like an escape hatch for the object prop problem.
export const Row = memo(
function Row({ item }: { item: Item }) {
return <li>{item.name}</li>;
},
// Only re-render when the fields this component reads have changed.
(prev, next) =>
prev.item.id === next.item.id && prev.item.name === next.item.name
);I use this rarely, and I am wary of it. The comparison returns true for equal, which is the opposite of shouldComponentUpdate and trips up anyone who came from class components. Worse, it is a hand-maintained list of the fields the component reads. Add a field to the JSX six months later, forget to add it to the comparator, and you get a component that renders stale data with no error anywhere. Deep equality libraries make it worse, not better, because now you are paying a recursive walk on every render to avoid a render.
If you find yourself writing one, the shape of the props is usually the real problem. Passing item.id and item.name as separate primitive props removes the need for the comparator entirely.
What memoisation will not fix
Two limits worth internalising, because both send people down long debugging paths.
useMemo is a cache, not a guarantee. React reserves the right to throw away memoised values, and does so in practice: it discards the cache for offscreen content, and the documentation is explicit that you should write code that still works if the memo is dropped. So never put anything with a side effect inside useMemo, and never rely on it for identity that must hold, such as a key into a Map that outlives the render. If you need a value that is stable for the lifetime of the component no matter what, that is a ref, not a memo.
A slow render is not always a re-render problem. If a single render of a component takes 200ms, memoisation reduces how often you pay that, but the first paint still costs 200ms and so does every render where a dependency legitimately changed. That is a different problem, solved by rendering less: virtualising a long list, splitting the work across frames, or moving the computation to the server. Reaching for React.memo there treats a symptom.
The React Compiler changes the calculation
React 19 shipped alongside the React Compiler, which analyses your components at build time and inserts memoisation automatically. Where it applies, hand-written useMemo and useCallback become redundant, and the guidance shifts from "memoise carefully" to "write plain code and let the compiler do it".
I would not rewrite an existing codebase around it yet. The compiler depends on your components following the rules of React strictly, and it bails out of components it cannot prove are safe, silently. What I do recommend is not adding new memoisation speculatively, because the odds are decent it becomes dead weight within a year or two.
A better first move than memoising
Before adding any of these three, check whether the re-render can be avoided by moving state instead. State that lives higher than it needs to re-renders everything beneath it, and no amount of React.memo fixes the structure.
// Every keystroke re-renders ExpensiveTree.
function Page() {
const [text, setText] = useState('');
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<ExpensiveTree />
</>
);
}
// Push the state down. ExpensiveTree is now a sibling that never re-renders.
function Page() {
return (
<>
<TextField />
<ExpensiveTree />
</>
);
}
function TextField() {
const [text, setText] = useState('');
return <input value={text} onChange={(e) => setText(e.target.value)} />;
}No hooks, no dependency arrays, and the problem is gone rather than managed. The same reasoning applies to Context: a provider whose value changes often re-renders every consumer, and splitting the context is a better fix than memoising downstream. I went into that trade-off in the React Context API guide, and the broader question of where state should live is the subject of React state management.
What I would actually do
Write the component without any memoisation. Ship it. If something feels slow, open the React DevTools Profiler, record the interaction, and look at what actually took time. Then memoise the specific thing the profiler pointed at.
The exceptions where I reach for it immediately, without profiling, are the two structural ones: a value that has to stay referentially stable because an effect or a memoised child depends on it, and a callback in a dependency array that would otherwise cause an infinite loop. Those are correctness, and correctness does not wait for a profile.
Everything else is a performance hypothesis, and performance hypotheses are wrong often enough that measuring is cheaper than guessing.
Frequently asked questions
What is the difference between useMemo and useCallback?
They are the same mechanism. useMemo calls the function you give it and caches the returned value. useCallback caches the function itself without calling it. useCallback(fn, deps) is exactly equivalent to useMemo(() => fn, deps). Use useCallback for functions and useMemo for everything else.
Does useCallback improve performance on its own?
No. Passing a memoized callback to a component that is not wrapped in React.memo changes nothing, because the child re-renders whenever the parent does regardless of whether its props changed. useCallback and React.memo only pay off as a pair. On its own, useCallback adds a hook, a dependency array and a comparison for no benefit.
When should I actually use useMemo?
Two cases. When a calculation is genuinely expensive, which for a rough threshold means over about 1ms measured with CPU throttling enabled. And when a value needs to stay referentially stable because an effect or a memoized child depends on it. The second reason is about correctness rather than speed, and it is the more common of the two.
Why does my React.memo component still re-render?
React.memo compares props with Object.is, which means reference equality for objects, arrays and functions. An inline arrow function or object literal in the parent creates a new reference on every render, so the comparison always fails. JSX passed as children has the same problem, which is why memoizing wrapper components rarely helps.


