Session 10 — useMemo, useCallback & React.memo
Before reaching for any memoization tool, you need to know what triggers a re-render in the first place. There are exactly three triggers:
Calling setState (or a reducer's dispatch) always schedules a re-render of that component.
By default, when a parent re-renders, every child re-renders too — even if that child's own props didn't change at all.
As you saw last session — every consumer of a context re-renders when the Provider's value changes.
Trigger #2 is the one that surprises people. Here's the chain: a totally unrelated state update in a top-level component cascades down and re-renders dozens of components that have nothing to do with that state.
One state update at the top re-renders every component below it by default — regardless of whether their own props changed.
A re-render that produces the same output is usually cheap — React still diffs the virtual DOM and skips real DOM updates. The problem is when a re-render triggers expensive work: a heavy calculation, a child tree with hundreds of nodes, or a function reference that breaks a child's own memoization. That's exactly what React.memo, useMemo, and useCallback exist to prevent.
React.memo wraps a component and tells React: "before re-rendering this, do a shallow comparison of its new props against its old props. If every prop is the same, skip the re-render entirely and reuse the last result."
import { memo } from 'react'; function ProductCard({ name, price }) { // Imagine this renders a complex card with images, formatting, etc. return ( <div className="card"> <h4>{name}</h4> <p>${price}</p> </div> ); } // Wrap the component — React now shallow-compares props before re-rendering export default memo(ProductCard); // Parent — only ProductCards whose own (name, price) changed actually re-render function ProductList({ products }) { return products.map(p => <ProductCard key={p.id} name={p.name} price={p.price} />); }
memo checks each prop with Object.is (basically ===). Primitives like strings and numbers compare by value, so price={9} stays equal across renders. But objects, arrays, and functions compare by reference — a brand-new {...}, [...], or () => {} created inline during render is a different reference every time, even if its contents look identical. That's the exact problem useMemo and useCallback solve.
// ✗ memo is wasted — a new object AND a new function every render function ProductList({ products }) { return products.map(p => ( <ProductCard key={p.id} style={{ color: 'red' }} {/* new object every render */} onSelect={() => select(p.id)} {/* new function every render */} /> )); } // Every ProductCard re-renders anyway — memo's shallow check always sees "changed" props
useMemo(calculateValue, dependencies) runs calculateValue once, caches the result, and only reruns it when one of the values in the dependency array changes. On every other render, it just returns the cached value instantly.
import { useMemo, useState } from 'react'; function countPrimesBelow(limit) { let count = 0; for (let n = 2; n <= limit; n++) { let isPrime = true; for (let d = 2; d * d <= n; d++) { if (n % d === 0) { isPrime = false; break; } } if (isPrime) count++; } return count; // expensive for a large limit } function PrimeFinder({ limit }) { const [theme, setTheme] = useState('dark'); // unrelated state // Without useMemo, countPrimesBelow runs on EVERY render — // including when only `theme` changes and `limit` is identical. const primeCount = useMemo(() => countPrimesBelow(limit), [limit]); return ( <div> <p>Primes below {limit}: {primeCount}</p> <button onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}> Toggle theme (unrelated re-render) </button> </div> ); }
You're telling React "this value only needs to be recalculated when one of these dependencies changes." If you list [limit], the calculation reruns only when limit changes — every other render reuses the cached number, no matter what else updates in the component.
This mirrors the code above. Switch modes, then click Force Re-render (simulating an unrelated state update, like the theme toggle) and watch whether the expensive calculation actually reruns. Then change the limit — a real dependency change — and see it rerun in both modes.
In "Without useMemo" mode, Calc Ran climbs on every single click — including "Force Re-render," which has nothing to do with the limit. In "With useMemo" mode, Calc Ran only climbs when you actually change the limit. The render count still goes up either way — only the expensive work is skipped.
Functions are values, just like objects and arrays. Every time a component renders, any function defined inside it — including inline arrow functions passed as props — is a brand-new function reference, even if the code inside is identical. useCallback(fn, dependencies) returns the same function reference across renders, as long as the dependencies haven't changed.
import { useCallback, useState, memo } from 'react'; const Child = memo(function Child({ onIncrement }) { console.log('Child rendered'); return <button onClick={onIncrement}>+1</button>; }); function Parent() { const [count, setCount] = useState(0); const [step] = useState(1); // Without useCallback, this is a NEW function every render — // Child's memo() sees a "changed" prop and re-renders anyway. const handleIncrement = useCallback(() => { setCount(c => c + step); }, [step]); // only recreated if `step` changes return ( <> <p>Count: {count}</p> <Child onIncrement={handleIncrement} /> </> ); }
useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). It exists as its own hook purely because memoizing callback props is such a common pattern. Both are pointless without something downstream that actually cares about referential stability — typically a memo-wrapped child, or another hook's dependency array.
The Child box below represents a memo-wrapped component that receives an onIncrement callback as a prop. Click Force Parent Re-render repeatedly and watch whether Child re-renders along with it. Then click Change Step — a real dependency change for the callback — and see Child react even in "with useCallback" mode.
In "Without useCallback" mode, the Child box flashes on every Parent re-render — even "Force Parent Re-render," which has nothing to do with the increment logic. In "With useCallback" mode, Child stays completely still during unrelated re-renders, and only flashes when "Change Step" actually changes the callback's dependency.
All three tools have a cost: a comparison check, extra memory to hold the cached value, and more code to read. Reach for them when you can point to a specific, measurable problem — not by default.
| Situation | Best tool | Why |
|---|---|---|
| Filtering a 10-item list on every keystroke | Nothing — just compute it inline | Too cheap to matter; memoizing adds overhead for no gain |
| Sorting 50,000 rows derived from a prop | useMemo |
Genuinely expensive; result only needs to change with the data |
| A button click handler passed to a plain (unmemoized) child | Nothing | The child re-renders anyway — useCallback buys nothing |
That same handler passed to a memo-wrapped child |
useCallback |
Now reference stability decides whether the child skips work |
| A list of 200 complex cards re-rendering on every parent tick | React.memo (+ stable props) |
Skips re-rendering entire subtrees that didn't actually change |
Open React DevTools → Profiler, record an interaction, and look for components that re-render with identical output, or renders that take an unusually long time. Memoization is the fix for a problem you've found — not a habit to apply everywhere "just in case."
Wrapping every component in memo and every value in useMemo "for performance." Each one adds a comparison cost — for cheap components and calculations, that cost can outweigh the savings.
Profile first. Reach for memoization on components that render often with stable props, or calculations that are visibly slow — not on a three-line component that renders in microseconds.
<Child style={{color:'red'}} onClick={() => ...} /> creates new references every render. memo on Child does nothing — its shallow prop check always sees "changed."
Wrap the object in useMemo and the function in useCallback in the parent, so the same references survive across renders — that's what lets the child's memo actually skip work.
useMemo(() => format(user), []) with an empty array caches forever — even after user changes, you keep seeing stale, outdated data.
Include every prop, state, or variable read inside the memoized function. The ESLint react-hooks/exhaustive-deps rule catches this automatically — keep it enabled.
React may discard a memoized value and recalculate it anyway in some situations (for example, to free memory). Never use useMemo for correctness — like skipping a required side effect. It's purely a performance optimization; your component must still produce correct output even if the memo "misses."
A parent re-rendering re-renders every child by default, regardless of whether that child's own props changed.
Skips re-rendering a component when its props are shallowly equal to last time. Broken by inline objects/functions as props.
Caches the result of a calculation. Reruns only when a listed dependency changes.
Caches a function reference. Same idea as useMemo, specialized for functions passed as props or dependencies.
memo, but its parent passes onClick={() => doThing()} inline. What happens?useMemo actually worth using?useMemo(() => formatUser(user), []) is called with an empty dependency array. What's the bug?You now know how to spot unnecessary work and cut it out with confidence. In a future session, we'll look at extracting this kind of logic into custom hooks — reusable, named pieces of stateful behavior you can share across components.