useCallback and useMemo: When They Actually Help

I've seen both extremes in code reviews: developers who never touch
or1useCallback
, and developers who wrap every single value and function in them "for performance." Both approaches hurt. The hooks exist to solve two specific problems, and using them outside those problems just adds noise and — yes — a small runtime cost.1useMemo
Let me walk through exactly when each one is worth it.
The core idea: referential equality
React re-renders a component when its state or props change. The catch is that JavaScript uses referential equality for objects and functions — two objects with identical contents are not the same value unless they share the same reference.
1const a = { x: 1 } 2const b = { x: 1 } 3console.log(a === b) // false — different references 4
Every time a React component re-renders, any object or function defined inside it gets a brand-new reference. That's usually fine. It becomes a problem in two specific situations, which map neatly to the two hooks.
1useCallback
— stabilizing a function reference
1useCallbackreturns the same function reference across renders, only creating a new one when a dependency changes.1useCallback(fn, deps)
When it earns its place
1. A function is listed as a
dependency1useEffect
1function SearchResults({ query }: { query: string }) { 2 const fetchResults = useCallback(async () => { 3 const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`) 4 return res.json() 5 }, [query]) 6 7 useEffect(() => { 8 fetchResults().then(setResults) 9 }, [fetchResults]) // stable reference — only re-runs when query changes 10} 11
Without
,1useCallback
is a new function on every render, so the1fetchResults
fires on every render regardless of whether1useEffect
actually changed.1query
2. A callback is passed to a child wrapped in 1React.memo
skips re-rendering a child when its props haven't changed. But if you pass an inline callback, it gets a new reference each time and1React.memo
never gets to skip anything.1React.memo
1const ExpensiveList = React.memo(function ExpensiveList({ 2 items, 3 onDelete, 4}: { 5 items: string[] 6 onDelete: (item: string) => void 7}) { 8 return ( 9 <ul> 10 {items.map(item => ( 11 <li key={item}> 12 {item} <button onClick={() => onDelete(item)}>×</button> 13 </li> 14 ))} 15 </ul> 16 ) 17}) 18 19function Parent({ items }: { items: string[] }) { 20 const [log, setLog] = useState<string[]>([]) 21 22 // Stable reference — ExpensiveList only re-renders when items changes 23 const handleDelete = useCallback((item: string) => { 24 setLog(prev => [...prev, `Deleted: ${item}`]) 25 }, []) 26 27 return <ExpensiveList items={items} onDelete={handleDelete} /> 28} 29
When it does NOT help
If you're just defining a function inside a component and calling it locally in event handlers,
buys you nothing. The creation cost of a plain function is essentially zero.1useCallback
1// This is pointless — no one depends on this reference staying stable 2const handleClick = useCallback(() => { 3 console.log('clicked') 4}, []) 5
1useMemo
— skipping expensive recalculations
1useMemocaches the return value of1useMemo(fn, deps)
and only recomputes it when a dependency changes.1fn
When it earns its place
1. Genuinely expensive computation
The classic case is filtering or sorting a large dataset on every render:
1function ProductList({ products, filter }: { products: Product[]; filter: string }) { 2 const filtered = useMemo( 3 () => 4 products.filter(p => 5 p.name.toLowerCase().includes(filter.toLowerCase()) 6 ), 7 [products, filter] 8 ) 9 10 return <ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul> 11} 12
If
has thousands of entries and the parent re-renders frequently for unrelated reasons, memoizing the filter result is worthwhile.1products
2. Stable object/array references passed to memoized children
The same referential equality problem that affects functions also affects objects and arrays:
1function Dashboard({ userId }: { userId: number }) { 2 // Without useMemo, this object is new on every render 3 const queryConfig = useMemo( 4 () => ({ userId, includeArchived: false }), 5 [userId] 6 ) 7 8 return <DataGrid config={queryConfig} /> 9} 10
If
is wrapped in1DataGrid
and receives1React.memo
as a prop, you need a stable reference or1queryConfig
is useless.1React.memo
When it does NOT help
Cheap calculations — string concatenation, simple arithmetic, short array maps — are not worth memoizing. The overhead of checking the dependency array often costs more than just recomputing.
1// Not worth it — this is not expensive 2const displayName = useMemo(() => `${first} ${last}`, [first, last]) 3// Just write: const displayName = `${first} ${last}` 4
The decision rule
Before reaching for either hook, ask two questions:
- Is a function or object being compared by reference (in a
dep array, or as a prop to a1useEffect
component)?1React.memo - Is there a measurably expensive computation happening on every render?
If neither is true, skip the hook. Add it when you have a concrete reason — ideally after profiling with React DevTools — not as a precaution. Premature memoization makes code harder to read without making it faster.
Both hooks are genuinely useful. They just need to be aimed at a real target.
