useCallback and useMemo: When They Actually Help

5 min read

useCallback and useMemo: When They Actually Help

I've seen both extremes in code reviews: developers who never touch

1useCallback
or
1useMemo
, 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.

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

1useCallback(fn, deps)
returns the same function reference across renders, only creating a new one when a dependency changes.

When it earns its place

1. A function is listed as a

1useEffect
dependency

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
,
1fetchResults
is a new function on every render, so the
1useEffect
fires on every render regardless of whether
1query
actually changed.

2. A callback is passed to a child wrapped in

1React.memo

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 and
1React.memo
never gets to skip anything.

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,

1useCallback
buys you nothing. The creation cost of a plain function is essentially zero.

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

1useMemo(fn, deps)
caches the return value of
1fn
and only recomputes it when a dependency changes.

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

1products
has thousands of entries and the parent re-renders frequently for unrelated reasons, memoizing the filter result is worthwhile.

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

1DataGrid
is wrapped in
1React.memo
and receives
1queryConfig
as a prop, you need a stable reference or
1React.memo
is useless.

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:

  1. Is a function or object being compared by reference (in a
    1useEffect
    dep array, or as a prop to a
    1React.memo
    component)?
  2. 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.