I8 · Part 2 · Intermediate
Memoisation
memo, useMemo and useCallback are a cache — and a cache you did not measure is a cost.
09On this page
What actually happens on a re-render
When a component re-renders, React re-renders all of its children by default, regardless of whether their props changed. That sounds alarming and usually is not: rendering is running a function and comparing objects, which is fast. The DOM is only touched where the output differs.
Memoisation is for the cases where that assumption breaks — a genuinely expensive calculation, or a large subtree re-rendering on every keystroke.
The three tools
| Tool | Caches | Skips |
|---|---|---|
useMemo(fn, deps) | A computed value | Recomputing it |
useCallback(fn, deps) | A function identity | Recreating it |
memo(Component) | A component's output | Re-rendering it when props are shallow-equal |
useCallback(fn, deps) is exactly useMemo(() => fn, deps).
It exists because that pattern is common enough to deserve a name.
memo, and why it so often does nothing
const Row = memo(function Row({ item, onSelect }) { ... });
<Row item={item} onSelect={() => select(item.id)} /> // ✗ memo defeated
memo does a shallow comparison of props. The arrow is a new
function on every render, so the comparison always fails and the component
re-renders anyway — now with the added cost of the comparison. The same
is true of style={{ ... }}, options={[...]}, and any
object built inline.
const handleSelect = useCallback((id) => select(id), [select]);
<Row item={item} onSelect={handleSelect} /> // ✓ stable
Which is the real lesson: memo is not one decision, it is a
contract with every call site. Miss one prop and the whole thing is inert.
Composition beats memo
// ✗ SlowTree re-renders on every keystroke
function Page() {
const [q, setQ] = useState("");
return (<><input value={q} onChange={...} /><SlowTree /></>);
}
// ✓ the state moved down; SlowTree is now a sibling that never re-renders
function Page() {
return (<><SearchBox /><SlowTree /></>);
}
Moving state down, or passing the expensive subtree in as
children, solves the same problem with no cache to maintain and
no call sites to keep in line. Try this first, every time.
Watch what re-renders
useMemo: when it is worth it
const sorted = useMemo(
() => [...rows].sort(compare),
[rows]
); // ✓ 10,000 rows, sorted on every keystroke otherwise
const total = useMemo(() => a + b, [a, b]); // ✗ the memo costs more than the addition
useMemo is not free: it stores the value, stores the dependency
array, and compares it on every render. For arithmetic and short strings the
bookkeeping is more expensive than recomputing.
The second legitimate use has nothing to do with speed: referential stability. If a value goes into a dependency array or into a memoised child, memoising it stops that thing running every render.
const value = useMemo(() => ({ user, login }), [user, login]);
<AuthContext value={value}> // ✓ or every consumer re-renders always
The rule that keeps this sane
- Measure first. React DevTools Profiler, with "record why each component rendered" on. Find the component that is actually slow.
- Fix the structure. Move state down, pass children, split the component. This removes the problem rather than caching it.
- Then memoise, at the specific place the profiler pointed at.
Memoising everything as a habit makes the code harder to read, adds real overhead, and hides the one place it was needed among fifty where it was not.
Dependency arrays lie the same way here
const filtered = useMemo(
() => rows.filter((r) => r.type === type),
[rows] // ✗ type missing — stale forever
);
A missing dependency in useMemo is worse than in an effect,
because it produces a wrong value rather than a skipped side effect,
and it is silent.
The comparison is shallow, and you can override it
const Row = memo(Component, (prev, next) => prev.item.id === next.item.id);
Returning true means "equal, skip the render" — the opposite
polarity from Array.prototype.sort's comparator, which catches
people out. Custom comparators are rarely the right answer; a stable prop
usually is.
The React Compiler changes the advice
React Compiler 1.0 shipped on 7 October 2025. It is a build-time plugin that reads your components and hooks, works out which values depend on which, and inserts the memoisation for you. Everything above still explains why a re-render happens; the compiler changes who writes the cache.
npm install --save-dev --save-exact babel-plugin-react-compiler@latest
| Where | Is it on? |
|---|---|
| Expo SDK 54 and later | On by default in new apps |
create-vite, create-next-app | Offered as a compiler-enabled template — you choose it |
| An existing app | Off until you add the plugin |
| React 17 or 18 | Works, with react-compiler-runtime added as a dependency |
What it does better than hand-written memo
function Row({ item, onSelect }) {
if (!item.visible) return null;
const label = formatLabel(item); // the compiler can cache this
return <li onClick={() => onSelect(item.id)}>{label}</li>;
}
You cannot call useMemo after an early return — it would
break the rules of hooks. The compiler is not a hook, so it can memoise
conditionally, after the return null, and it caches the
inline arrow too. That is the category of optimisation manual memoisation could
never reach.
What to do with the useMemo you already wrote
The React team's own guidance is to leave existing memoisation in place,
or remove it only with testing, because deleting a useMemo can
change what the compiler outputs. For new code, rely on the compiler and keep
useMemo/useCallback as an escape hatch for the few
places you need exact control — a value that must stay referentially
stable for an effect dependency, for example.
Opting in and out, one function at a time
function LegacyChart() {
"use no memo"; // skip this one until it is fixed
...
}
function Checkout() {
"use memo"; // compile this one in annotation mode
...
}
compilationMode | What gets compiled |
|---|---|
annotation | Only functions marked "use memo" — the safe way to start on a large app |
infer | What the compiler decides looks like a component or hook; directives override it |
all | Everything, except functions marked "use no memo" |
Either directive can also sit at the top of a module to cover every function in
the file. Treat "use no memo" as a temporary marker with a ticket
behind it, not a permanent setting.
The compiler needs code that follows the rules
It assumes components are pure and props and state are not mutated. Code that
breaks those rules either gets skipped or behaves differently once cached. The
recommended config of eslint-plugin-react-hooks now carries
compiler-powered rules that catch exactly those patterns:
| Rule | Catches |
|---|---|
set-state-in-render | A setState during render that loops |
set-state-in-effect | State set synchronously inside an effect — usually a derived value that should be computed during render |
refs | Reading or writing ref.current during render |
// eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";
export default [reactHooks.configs.flat.recommended];
Adopting it on an existing app
- Turn on the lint rules first and fix what they report. That is most of the work, and it is worth doing even without the compiler.
- Add the plugin in
annotationmode and mark a few stable, well-tested components with"use memo". - Profile before and after with the React DevTools Profiler — measure, the same way the rest of this chapter says to.
- Move to
infer, and mark anything that misbehaves"use no memo"until it is fixed.
Interview answer, one sentence
"Re-rendering is usually cheap, so memoisation is a targeted fix rather than
a default. memo only helps if every prop is referentially
stable, which is why an inline arrow silently defeats it — and moving state
down or passing children solves the same problem without a cache to keep in
sync. With the React Compiler enabled, it applies that memoisation
automatically, so manual memo and useMemo are
kept for the cases it cannot see."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series