useMemo, useCallback and memo
memo skips a child's render when its props are shallowly equal, and useMemo and useCallback keep props equal. How the comparison works, when it helps, and when it is wasted.
After this lesson you can
- Predict whether a memo-wrapped component re-renders from its props
- Use useMemo and useCallback to keep object and function props stable
- Explain why children, inline objects and context changes defeat memo
- Decide whether memoisation is worth adding at all
Lesson 5 established that a component re-renders whenever its parent does. Usually that is cheap. When a child is expensive to render and its parent renders often, three tools let you skip work:
memo(Component)— skip re-rendering the component when its props are equal to last time.useMemo(() => compute(a, b), [a, b])— keep a computed value between renders, recomputing only when a dependency changes.useCallback(fn, [a, b])— keep the same function between renders. It is exactlyuseMemo(() => fn, [a, b]).
How memo compares props
memo compares the previous and next props shallowly: same set of
keys, and each value equal by Object.is. Nothing deeper. So the question
for every prop is where its value was created. Here one parent renders
twice with exactly the same state, and each prop is compared with its
previous value:
// Created once, outside any render (a module constant, or a ref).const reset = () => {}; // The parent's render body: everything written here is built again// each time the parent renders.function parentRender(state) { return { label: state.label, // a string from state ratio: state.done / state.total, // NaN while total is 0 style: { color: "teal" }, // object literal in the render onSave: () => {}, // arrow function in the render onReset: reset, // the same function every time };} export function run() { const state = { label: "Save", done: 0, total: 0 }; const before = parentRender(state); const after = parentRender(state); // same state, second render const sameByObjectIs = {}; for (const name of Object.keys(before)) { sameByObjectIs[name] = Object.is(before[name], after[name]); } return { sameByObjectIs, ratioWithTripleEquals: before.ratio === after.ratio, memoSkips: Object.values(sameByObjectIs).every(Boolean), };}Two consequences matter. An object, array or function created during the
parent's render is new every time, so it is never equal — style={{ color: "red" }} or onClick={() => …} silently turns memo off. And JSX is an
object too: <Memoised><Icon /></Memoised> passes a new children
element each render, so the memoised component re-renders anyway.
Keeping props stable
That is what useMemo and useCallback are for. They return the cached
value as long as every dependency is Object.is-equal to last time:
const filtered = useMemo(() => todos.filter(matches(query)), [todos, query]);
const onToggle = useCallback((id: number) => {
setTodos((ts) => ts.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
}, []);
return <TodoList items={filtered} onToggle={onToggle} />; // TodoList is memo()
// One useMemo slot: recompute only when a dependency changes by Object.is.function createUseMemo() { let slot = null; let computations = 0; return { useMemo(factory, deps) { if (slot && deps.every((d, i) => Object.is(d, slot.deps[i]))) return slot.value; computations++; slot = { deps, value: factory() }; return slot.value; }, computations: () => computations, };} export function run() { const hook = createUseMemo(); const todos = [{ text: "milk" }, { text: "eggs" }, { text: "bread" }]; const results = []; for (const query of ["e", "e", "e", "b"]) { const filtered = hook.useMemo(() => todos.filter((t) => t.text.includes(query)), [todos, query]); results.push(filtered); } return { computations: hook.computations(), sameArrayAcrossFirstThree: results[0] === results[1] && results[1] === results[2], last: results[3], };}onToggle uses an updater function, so it needs no dependencies and stays
the same function forever.
What memo does not stop
- State changes inside the component and context changes it reads both re-render it; memo only looks at props from the parent.
- Equal-looking but new props, as above.
You can pass a custom comparison as memo's second argument, but a wrong
one causes stale UI, and a deep comparison can cost more than the render.
When to bother
Memoisation is an optimisation. The code must be correct without it, and
every useMemo adds a dependency comparison and keeps memory alive. Use
it when a render is measurably slow (the React DevTools Profiler shows
you), when a value is passed to a memo child, or when a value is an
effect dependency that would otherwise change every render. Elsewhere,
better structure — moving state down, passing JSX as children — often
removes the re-render entirely. React Compiler, when a project enables it,
inserts this memoisation automatically at build time.
Common mistakes
- Wrapping a child in
memoand passing it an inline object or arrow function. The prop is new each render, so memo never skips; the style object case in the first example shows why the comparison fails. useMemowith a dependency that is itself recreated every render, such as an options object, so the cache never hits.- Expecting memo to block a context update. Consumers re-render when the context value changes, memo or not.
Exercise · Pro
Who re-renders when the owner did not
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade