The previous lesson established that a component re-runs for four reasons, and that the second — an ancestor has re-rendered — is responsible for most of the wasted work in a React application. It also established that the first response to that problem is structural: push state down, pass children, paginate. But there are cases with no structural headroom left: CataloguePage has to repaint whenever the search term changes, and with it the 2,000 BikeCard instances in the catalogue re-run too, including the 1,999 whose data is exactly the same as an instant ago. That's what React.memo is for: a wrapper that tells React "before running this function, compare its props against last time's; if they're equal, skip it and reuse the previous result." It's a simple tool with a one-line API, and yet it's the most misused one in the ecosystem, because half of the memo calls out there in production do absolutely nothing. This lesson covers exactly what it does, how it applies to the CicloUrbano catalogue, why it fails so often, what it never blocks, and how much it costs to put it where it doesn't belong.
Contents
- What memoization means
React.memo: exact signature and behavior- The render tree with and without
memo - Case study:
BikeCardinsideBikeList - Why
memooften does nothing - Diagnosis: reproducing the failure and seeing it
- The custom comparator
- Inverted signature compared to
shouldComponentUpdate - What
memoblocks and what it never blocks - When to apply it and when not to
- The cost of
memo memoand the React Compiler
- What memoization means
Memoization is storing the result of an operation keyed by its inputs, so that a repeated call with the same inputs returns the stored result instead of repeating the operation. It's a general programming technique, and its simplest form has nothing to do with React:
// Manual memoization of a pure function
function memoize(fn) {
const cache = new Map();
return function (arg) {
if (cache.has(arg)) {
return cache.get(arg); // nothing gets recalculated
}
const result = fn(arg);
cache.set(arg, result);
return result;
};
}
const calculateTotalPrice = memoize((hours) => hours * 2.5);
calculateTotalPrice(3); // calculates: 7.5
calculateTotalPrice(3); // returns the cached value, no calculationThis gives us the two conditions that make memoization worthwhile, and they apply equally to React.memo:
- The function must be pure: same inputs, same result, no side effects. If it isn't, returning a cached result produces incorrect behavior.
- Recalculating must be more expensive than checking and storing. In the example above,
hours * 2.5is cheaper than looking something up in aMap: memoization makes it worse.
A React component is a pure function of its props (that's one of the rules from 04-04), so it's memoizable by definition. And the second condition — that running it is more expensive than comparing its props — is exactly what you need to verify before applying memo, and what almost nobody verifies.
React.memo: exact signature and behavior
React.memo: exact signature and behaviormemo takes a component and returns another component with the same behavior plus an upfront check. Here's precisely what React does when it's time to render a wrapped component:
- It retrieves the props from the previous render.
- It compares both props objects shallowly: it walks the top-level keys and compares each value with
Object.is. - If all of them are equal, it doesn't call the component function and reuses the element tree it produced last time.
- If any of them differ, it runs the component as normal.
The two qualifiers in that comparison are the source of every problem and every solution in this lesson:
Shallow means a single level of depth.
const prevProps = { bike: { id: 'bici-001', pricePerHour: 2.5 }, active: false };
const nextProps = { bike: { id: 'bici-001', pricePerHour: 2.5 }, active: false };
Object.is(prevProps.active, nextProps.active); // true → primitive, same value
Object.is(prevProps.bike, nextProps.bike); // false → different objects with equal content
// Result: memo decides the props HAVE changed and rendersBy identity means Object.is, not structural equality. For primitives (string, number, boolean, null, undefined) it matches what you'd expect. For objects, arrays, and functions, it compares the reference in memory, not the content.
| Prop value | Does Object.is return true with the same content? |
|---|---|
'bici-001' |
✅ Yes |
2.5 |
✅ Yes |
true |
✅ Yes |
{ id: 'bici-001' } |
❌ No, if it's a new object |
['urbana', 'electrica'] |
❌ No, if it's a new array |
() => book(id) |
❌ No: every render creates a new function |
<StatusBadge /> as a prop or children |
❌ No: every render creates a new element |
This table is, in practice, the outline of the lesson: the bottom half explains why so many memo calls don't work.
- The render tree with and without
memo
memoflowchart TD
subgraph WITHOUT["WITHOUT memo: the whole subtree runs"]
A1["CataloguePage<br/>term changes"] --> B1["BikeSearch<br/>runs"]
A1 --> C1["FleetSummary<br/>runs"]
A1 --> D1["BikeList<br/>runs"]
D1 --> E1["BikeCard x 2,000<br/>ALL of them run"]
end
flowchart TD
subgraph WITH["WITH memo: React compares before going down"]
A2["CataloguePage<br/>term changes"] --> B2["BikeSearch<br/>runs"]
A2 --> C2{"FleetSummary memoized<br/>props equal?"}
C2 -->|Yes| C3["Does NOT run<br/>previous tree is reused"]
A2 --> D2["BikeList<br/>runs: its prop changed"]
D2 --> E2{"BikeCard memoized<br/>props equal?"}
E2 -->|"Yes, for 1,988 cards"| E3["Do NOT run"]
E2 -->|"No, for 12 cards"| E4["Only those run"]
end
The important detail in the second diagram: when memo cuts, it cuts the entire subtree. React doesn't just skip FleetSummary; it also skips everything FleetSummary would have rendered inside it. That's why the benefit of memo grows with the size of the subtree it protects, and why putting it on a cheap leaf rarely pays off.
- Case study:
BikeCard inside BikeList
BikeCard inside BikeListHere's the current state of the CicloUrbano catalogue, now expanded to 2,000 bikes.
// src/components/BikeCard.jsx (current version, unmemoized)
import StatusBadge from './StatusBadge.jsx';
import styles from './BikeCard.module.css';
function BikeCard({ bike, stationName, onSelect, onBook }) {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
});
return (
<article className={styles.card}>
<h3>{bike.model}</h3>
<p className={styles.station}>{stationName}</p>
<StatusBadge status={bike.status} />
<p className={styles.price}>{formatter.format(bike.pricePerHour)} / hour</p>
<button type="button" onClick={() => onSelect(bike.id)}>
View details
</button>
<button
type="button"
onClick={() => onBook(bike.id)}
disabled={bike.status !== 'disponible'}
>
Book
</button>
</article>
);
}
export default BikeCard;Notice the new Intl.NumberFormat(...) inside the function body: creating an Intl formatter is one of the most expensive operations you can put in a render, on the order of 0.05–0.1 ms each. Multiplied by 2,000 cards that's between 100 and 200 ms for every single keystroke in the search box. This component isn't cheap.
Memoizing it is a one-liner:
// src/components/BikeCard.jsx (memoized)
import { memo } from 'react';
import StatusBadge from './StatusBadge.jsx';
import styles from './BikeCard.module.css';
// The formatter moves outside the component: it's a constant, it depends on nothing
const EURO_FORMATTER = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
});
function BikeCard({ bike, stationName, onSelect, onBook }) {
return (
<article className={styles.card}>
<h3>{bike.model}</h3>
<p className={styles.station}>{stationName}</p>
<StatusBadge status={bike.status} />
<p className={styles.price}>{EURO_FORMATTER.format(bike.pricePerHour)} / hour</p>
<button type="button" onClick={() => onSelect(bike.id)}>
View details
</button>
<button
type="button"
onClick={() => onBook(bike.id)}
disabled={bike.status !== 'disponible'}
>
Book
</button>
</article>
);
}
export default memo(BikeCard);Two changes, and the order they're made in isn't an accident:
- Move
Intl.NumberFormatoutside the component. This is the real optimization: it eliminates 2,000 object constructions per render without memoizing anything, and it keeps working even ifmemonever hits. It's technique 3 from 08-01 — avoid the work, don't just speed it up — applied to a constant. - Wrap the export in
memo. The project convention (export defaultfor components) is preserved: memoization happens at the export, and the function keeps its name, which is what you'll see in React DevTools and in the Profiler.
Notice the detail of naming the function (
function BikeCard(...)) instead of using an anonymous arrow. If you writeexport default memo(({ bike }) => …), React DevTools will show the component asAnonymousand the Profiler will be much less useful.
And now the question that decides everything: with this memo, how many cards skip rendering when the user types a letter in the search box?
None. Not a single one. Let's see why.
- Why
memo often does nothing
memo often does nothingHere's the parent component as it's written today in CicloUrbano:
// src/components/BikeList.jsx (the problem)
import { useNavigate } from 'react-router';
import BikeCard from './BikeCard.jsx';
import styles from './BikeList.module.css';
function BikeList({ bikes, stations }) {
const navigate = useNavigate();
return (
<ul className={styles.grid}>
{bikes.map((bike) => (
<li key={bike.id}>
<BikeCard
bike={bike}
stationName={
stations.find((st) => st.id === bike.stationId)?.name ?? '—'
}
onSelect={(id) => navigate(`/bicicletas/${id}`)}
onBook={(id) => navigate(`/reservas/nueva?bicicleta=${id}`)}
/>
</li>
))}
</ul>
);
}
export default BikeList;Every time BikeList runs, the onSelect and onBook arrow functions get created again. They're functions with the same code and the same behavior, but different objects in memory. When memo compares:
Object.is(prevProps.bike, nextProps.bike); // true ✅ same object from the Query cache
Object.is(prevProps.stationName, nextProps.stationName); // true ✅ string
Object.is(prevProps.onSelect, nextProps.onSelect); // false ❌ new function
// → memo concludes the props have changed and renders anywayIt only takes one prop with an unstable identity to cancel out memo entirely. And it's not just that nothing gets saved: now, on top of running all 2,000 cards, React also runs 2,000 props comparisons that always fail. memo has made things worse.
Here's the complete catalogue of props that break memo, in the form they usually take in CicloUrbano:
| Unstable prop | Real example | Why it fails |
|---|---|---|
| Inline arrow function | onBook={(id) => navigate(...)} |
New function on every render of the parent |
| Object literal | style={{ margin: 8 }} |
New object on every render |
Inline style |
style={{ opacity: 0.5 }} |
Same case as above, and the most common of all |
| Array literal | types={['urbana', 'electrica']} |
New array on every render |
Result of .map/.filter |
visible={bikes.filter(...)} |
New array even when the content is identical |
| JSX element | icon={<StatusBadge status="libre" />} |
New element on every render |
children |
<Panel>{content}</Panel> |
children is just another prop, and it almost always changes identity |
| Object built on the fly | user={{ id, name }} |
New object even when id and name don't change |
Practical rule:
memoonly works if every prop is a primitive, or is an object whose identity someone keeps stable. In CicloUrbano,bikeis stable because it comes from the TanStack Query cache, which returns the same objects as long as the data hasn't revalidated. Functions aren't, and that's where the failure lies.
The fix for this problem isn't in this lesson: it's in 08-03. useCallback stabilizes the identity of functions, and useMemo stabilizes that of objects and arrays. memo and useCallback are two halves of the same tool, and using one without the other is usually wasted effort. The next lesson closes this case with the definitive version of BikeList.
- Diagnosis: reproducing the failure and seeing it
Rather than waiting for 08-05, there's an immediate way to check whether a memo is working, and it consists of temporarily instrumenting the component:
// Temporary instrumentation, for diagnosis only
function BikeCard({ bike, stationName, onSelect, onBook }) {
console.count(`render BikeCard ${bike.id}`);
// …
}console.count keeps a running count per label. Type five letters into the search box with the 2,000-bike catalogue loaded and watch the console:
| Situation | Expected count after 5 keystrokes |
|---|---|
Without memo |
Every card reaches 5 (or 6, counting the initial render) |
With memo and unstable props |
Exactly the same: memo doesn't cut anything |
With memo and stable props |
Every card stays at 1, except the ones entering or leaving the filter |
A somewhat finer diagnosis, one that also tells you which prop is the culprit:
// src/utils/debug.js
export function comparePropsWithTrace(name) {
return function (prevProps, nextProps) {
const keys = new Set([...Object.keys(prevProps), ...Object.keys(nextProps)]);
for (const key of keys) {
if (!Object.is(prevProps[key], nextProps[key])) {
console.log(`[${name}] prop "${key}" changed`, {
before: prevProps[key],
after: nextProps[key]
});
}
}
return false; // false = "not equal" → let it render; we're only observing
};
}
// TEMPORARY use, never in production:
// export default memo(BikeCard, comparePropsWithTrace('BikeCard'));With the current BikeList, the console fills up with prop "onSelect" changed and prop "onBook" changed, which is the exact diagnosis. Remember to remove it afterwards: walking the prop keys of 2,000 components on every render is exactly the kind of cost this module is trying to eliminate.
- The custom comparator
memo accepts a second argument: a function that decides the comparison on your behalf.
The contract is precise:
- Return
trueif you consider the props equal → React doesn't render. - Return
falseif they're different → React renders.
A legitimate use in CicloUrbano: StationCard receives the full station object, but only paints the name, the district, and the number of free docks. If the object also carries an incident history that changes constantly without affecting what's on screen, the default comparison would re-render for nothing.
// src/components/StationCard.jsx
import { memo } from 'react';
function StationCard({ station, freeDocks, onOpen }) {
return (
<article>
<h3>{station.name}</h3>
<p>{station.district}</p>
<p>{freeDocks} of {station.docks} docks free</p>
<button type="button" onClick={() => onOpen(station.id)}>View details</button>
</article>
);
}
// Only three fields and the callback matter; the rest of the object is noise
function arePropsEqual(prevProps, nextProps) {
return (
prevProps.station.id === nextProps.station.id &&
prevProps.station.name === nextProps.station.name &&
prevProps.station.docks === nextProps.station.docks &&
prevProps.freeDocks === nextProps.freeDocks &&
prevProps.onOpen === nextProps.onOpen
);
}
export default memo(StationCard, arePropsEqual);The risks, which are serious and explain why it's used so little:
- It's a promise you have to keep. If tomorrow the card starts showing
station.districtand nobody updates the comparator, the district will never update on screen. It's a silent failure, hard to reproduce, and the linter won't catch it. childreninvalidates it almost every time. If the component receiveschildren, comparing them correctly is practically impossible.- Deep comparison is usually more expensive than rendering. A
JSON.stringify(prev) === JSON.stringify(next)over a medium-sized object costs more than running a simple component, and on top of that it fails with functions, dates, andundefined.
// ❌ Classic antipattern
export default memo(Card, (a, b) => JSON.stringify(a) === JSON.stringify(b));Rule: if you need a custom comparator, the real problem is almost always that the component receives props it doesn't need. Passing
name,district, andfreeDocksas primitives instead of the whole object removes the problem without a comparator and withoutmemo.
- Inverted signature compared to
shouldComponentUpdate
shouldComponentUpdateIn 04-03, while covering the legacy lifecycle, shouldComponentUpdate showed up as its ancestor in class components. The comparison is useful, and its most dangerous difference is the meaning of the returned value.
shouldComponentUpdate(nextProps, nextState) |
memo(C, (prevProps, nextProps)) |
|
|---|---|---|
| Where it lives | Method on a class | Wrapper around a function |
| Question it answers | "Should I update?" | "Are they equal?" |
true means |
Render | Don't render |
false means |
Don't render | Render |
| Access to state | Yes, also compares nextState |
No: props only |
| Lazy version | PureComponent (shallow comparison) |
memo with no second argument |
The confusion is common enough to be worth pinning down in one sentence: shouldComponentUpdate answers "update", memo answers "are they equal". Returning true in the wrong place produces the worst possible bug in an interface: a component that stops updating, with no error message at all, discovered only when someone notices that a bike's status is stuck showing disponible.
And a deeper difference: memo doesn't see state. Which leads directly into the next section.
- What
memo blocks and what it never blocks
memo blocks and what it never blocksThis is the section that prevents the most misunderstandings. memo intervenes in only one of the four causes of re-render from 08-01.
| Render cause | Does memo block it? |
Explanation |
|---|---|---|
| An ancestor re-rendered and the props didn't change | ✅ Yes | This is exactly its job, and the only one |
| An ancestor re-rendered and some prop changed identity | ❌ No | The comparison fails and it renders |
Its own state changes (useState, useReducer) |
❌ Never | A component always renders when its state changes |
A context it consumes changes (useContext) |
❌ Never | The context subscription is internal and memo doesn't see it |
A subscribed external store changes (useSelector, useQuery) |
❌ Never | Same reason: the subscription lives inside the component |
A different key |
❌ No | Changing the key unmounts and remounts: there are no previous props to compare |
The two "never"s in the middle are the important ones, and the context one is the single biggest source of useless code in the wild:
// ❌ This memo does NOT prevent anything
const ThemeButton = memo(function ThemeButton() {
const { theme, toggleTheme } = useTheme(); // consumes context
return <button onClick={toggleTheme}>Theme: {theme}</button>;
});ThemeButton receives no props, so memo's comparison always comes back "equal"... and yet the component re-runs every time ThemeProvider's value changes, because consuming a context is a subscription, not a prop. Here, memo only adds an empty comparison on every render of the parent. What does work for that problem is what 07-02 covered: splitting the context into state and actions, and stabilizing its value (08-03).
One nuance that is actually useful, though: memo can stop the component from re-running because of its parent, and in that case the context isn't even consulted. In other words, memo doesn't block context propagation, but it can reduce how often the component runs for other reasons.
- When to apply it and when not to
Apply memo when all three conditions hold at once:
- The component re-runs often with the same props. You've checked it, not assumed it.
- Running it is expensive: a large subtree, many elements, computations, formatting with
Intl, or it's repeated hundreds of times in a list. - Its props are stable, or you can make them stable with
useMemo/useCallback.
The CicloUrbano cases that meet all three:
| Component | Why it pays off |
|---|---|
BikeCard |
2,000 instances; the parent repaints on every keystroke in the search box |
FleetSummary |
Iterates over the 2,000 bikes to aggregate by status; its props rarely change |
BookingsPanel |
Large subtree inside a page that repaints for other reasons |
NoticeList |
Hangs under Layout, which re-runs on every navigation |
Don't apply memo when:
| Situation | Why not |
|---|---|
The component is cheap (StatusBadge, LoadingIndicator, Breadcrumbs) |
Comparing costs more than running it |
| Its props almost always change | The comparison always fails: pure cost |
It receives children created in the parent |
The children prop invalidates the comparison almost every time |
It's already isolated by structure (receives children, or its parent doesn't repaint) |
The problem no longer exists |
It's a page (CataloguePage, BookingsPage) |
It renders when the route changes, which is when it should |
| It renders once per screen with no repetition | There's nothing to save |
| You're memoizing "just in case", without measuring | That's the definition of premature optimization |
- The cost of
memo
memomemo isn't free, and its cost has three parts:
- Comparison on every render of the parent. Walking the prop keys and calling
Object.isfor each one. It's cheap, but multiplied by 2,000 instances and by every keystroke, it stops being cheap. - Memory. React keeps the previous props and the element tree it produced around. With 2,000 memoized cards, that's real memory that doesn't get freed while the component stays mounted.
- Human cost. Every
memois an implicit contract: "this component's props must stay stable." Whoever adds an inlinestyle={{...}}tomorrow will break that contract without knowing it, andmemowill sit there as cost with no benefit, invisible forever.
Hence the conclusion that saves the most code in the whole lesson:
Filling a project with
memomakes the application slower, not faster. Everymemothat misses is comparison and memory in exchange for nothing, and the ones that miss are the majority if nobody has measured.
A thought experiment that makes this clear: if you wrap all 26 components in CicloUrbano in memo, you add 26 comparisons per render and a few dozen kilobytes of stored props. Of those 26, maybe 4 sit in a long list or protect an expensive subtree. The other 22 are pure cost. And none of the 4 will work unless their props are stable.
memo and the React Compiler
memo and the React CompilerAs mentioned in 08-01, the React Compiler in React 19 applies this same memoization automatically. In a compiled project, BikeCard skips its render when its props haven't changed without you writing memo, and — this is what really matters — the compiler also stabilizes the arrow functions that BikeList creates for every card, so the failure from section 5 never happens in the first place.
What that means in practice:
| With the compiler enabled | Situation |
|---|---|
Explicit memo on a component already optimized by the compiler |
Redundant, but harmless: it doesn't break anything |
| Components the compiler bails out on (they break React's rules) | Manual memo remains the only protection |
| Custom comparators | It doesn't replace them: they express a decision the compiler can't infer |
| Renders caused by own state or by context | Same as without the compiler: it doesn't prevent them |
| Projects without the compiler (the vast majority today) | Everything in this lesson is still exactly as necessary |
And the deeper reason this lesson still matters: when something goes wrong — a card that doesn't update, a render that doesn't get skipped — the diagnosis comes down to exactly the same question: which prop changed identity, and why. That reasoning is the same with the compiler and without it. The compiler saves you from writing memo; it doesn't save you from understanding it.
Common Mistakes and Tips
Wrapping in memo and leaving the props inline. This is mistake number one, and it produces the worst of both worlds: the same number of renders plus a comparison for each one. If you're going to memoize, stabilize the props (08-03), or don't memoize at all.
Believing memo prevents renders caused by state or context. It never does. A memo on a component that consumes useTheme or useSelector is decoration.
Inverting the custom comparator. Returning true for "it changed" produces a frozen component. Remember: memo asks "are they equal?".
memo(() => …) with an anonymous function. The Profiler and DevTools will show Anonymous, and you'll lose half the usefulness of 08-05. Always name the function.
Memoizing a component that receives children. children is a prop, and in 95% of cases it's a new element on every render of the parent. If your container component receives children, it's already isolated by structure (08-01, technique 2) and doesn't need memo.
Tip: memo is the last resort, not the first. Before reaching for it, ask: can I push the state down? can I pass children? can I paginate? can I pass primitives instead of the whole object? If any answer is yes, that's the solution.
Tip: pass primitives whenever you can. <BikeCard model={b.model} status={b.status} pricePerHour={b.pricePerHour} /> memoizes well with zero effort, because primitives are compared by value. It's more verbose and far more robust.
Tip: memoize the container, not the leaves. A memo on BikeList protects a subtree of 2,000 elements with a single comparison. A memo on every StatusBadge adds 2,000 comparisons to save 2,000 <span> elements. If you can cut higher up, cut higher up.
Exercises
Exercise 1. For each of these four uses of memo in CicloUrbano, state whether the memo works, doesn't work, or is unnecessary, and justify your answer in one sentence.
// a)
const StatusBadge = memo(function StatusBadge({ status }) {
return <span className={styles[status]}>{LABELS[status]}</span>;
});
// b)
const FleetSummary = memo(function FleetSummary({ bikes }) {
const byStatus = bikes.reduce((acc, b) => { /* … */ }, {});
return <dl>{/* … */}</dl>;
});
// Usage: <FleetSummary bikes={data.filter((b) => b.stationId === stationId)} />
// c)
const ThemeButton = memo(function ThemeButton() {
const { theme, toggleTheme } = useTheme();
return <button onClick={toggleTheme}>{theme}</button>;
});
// d)
const Panel = memo(function Panel({ title, children }) {
return <section><h2>{title}</h2>{children}</section>;
});Exercise 2. BookingPanel is wrapped in memo and still re-runs on every render of its parent. Find the three props that are stopping it, and explain what kind of value each one is. You don't need to fix them: that's 08-03.
function BikeDetailPage() {
const { bicicletaId } = useParams();
const { data: bike } = useBike(bicicletaId);
const [hours, setHours] = useState(1);
return (
<BookingPanel
bike={bike}
hours={hours}
rates={{ hour: bike.pricePerHour, deposit: 20 }}
allowedTypes={['urbana', 'electrica']}
onConfirm={(data) => createBooking(data)}
style={{ marginTop: 16 }}
/>
);
}Exercise 3. BikeCard receives the full bike object, but only uses model, status, pricePerHour, and id. The object comes from the TanStack Query cache and gets replaced entirely on every revalidation (every 30 seconds, per the catalogue's staleTime), even when the data is identical. Propose two different solutions — one with a custom comparator and one without memo at all — and argue which one you'd pick.
Solutions
Solution 1.
| Case | Verdict | Justification |
|---|---|---|
a) StatusBadge |
Unnecessary | It receives a primitive, so the comparison succeeds, but the component is a <span>: comparing costs the same or more than running it. Cost with no benefit |
b) FleetSummary |
Doesn't work | The bikes prop is the result of a .filter() at the call site: a new array on every render. The comparison always fails, and on top of that the component is expensive. It's the worst possible scenario |
c) ThemeButton |
Unnecessary | It receives no props, so memo always says "equal", but it re-runs regardless every time the theme context changes. memo doesn't block context |
d) Panel |
Doesn't work | children is a prop, and its element is created anew on every render of the parent. A container with children is already isolated by structure and doesn't need memo |
Solution 2. Three props break the comparison:
rates={{ hour: ..., deposit: 20 }}— object literal: new on every render even whenpricePerHourdoesn't change.allowedTypes={['urbana', 'electrica']}— array literal: new on every render, with constant content. It's the most absurd of the three cases, because the value doesn't depend on anything and could live outside the component.onConfirm={(data) => createBooking(data)}— inline arrow function: new on every render.- And a bonus fourth:
style={{ marginTop: 16 }}— object literal, the most common case of all.
bike is indeed stable (it comes from the Query cache) and hours is a number, compared by value. With four broken props out of six, memo doesn't skip a single render: it only adds the comparison.
Solution 3.
Option A, with a custom comparator:
function arePropsEqual(prevProps, nextProps) {
return (
prevProps.bike.id === nextProps.bike.id &&
prevProps.bike.model === nextProps.bike.model &&
prevProps.bike.status === nextProps.bike.status &&
prevProps.bike.pricePerHour === nextProps.bike.pricePerHour &&
prevProps.stationName === nextProps.stationName &&
prevProps.onSelect === nextProps.onSelect &&
prevProps.onBook === nextProps.onBook
);
}
export default memo(BikeCard, arePropsEqual);It works, but it creates a maintenance debt: the day the card starts showing bike.type, that field will stop updating on screen and nobody will get a warning.
Option B, without memo: pass primitives.
<BikeCard
id={bike.id}
model={bike.model}
status={bike.status}
pricePerHour={bike.pricePerHour}
stationName={stationName}
onSelect={onSelect}
onBook={onBook}
/>With primitive props, memo's default shallow comparison succeeds every time, with no comparator and no debt: if the object gets replaced but model, status, and pricePerHour hold the same values, Object.is returns true for all three. On top of that, the component's contract becomes explicit: you can see at a glance what it needs.
Which to pick: option B. It's more robust, self-documenting, and it can't go stale. The only argument for A is the convenience of passing a single object, and that convenience is paid for with a silent failure. The general rule: when a custom comparator seems necessary, check the props contract first.
Conclusion
React.memo does exactly one thing and does it well: it wraps a component and, before running it, compares its props by identity and shallowly against the previous render's; if they're all equal, it skips the run and reuses the element tree it produced last time, cutting off the entire subtree that hung from it in the process. Applied to BikeCard inside a 2,000-item catalogue, the potential is enormous — especially after moving Intl.NumberFormat outside the component, which is the optimization that works whether memo hits or not.
But that potential doesn't materialize on its own. memo compares by identity, so it only takes one inline arrow function, one object literal, one style={{}}, one array built on the fly, or one children to make the comparison fail every time and turn memo into pure cost. That's exactly what happens today in BikeList with onSelect and onBook, and now you know how to diagnose it with console.count and with a trace comparator that points at the culprit prop. The missing half — useMemo and useCallback to stabilize those identities — is the next lesson, and until then the catalogue's memo doesn't save a single render.
You've also seen the limits. The custom comparator (prevProps, nextProps) => boolean has the inverted signature compared to shouldComponentUpdate: here true means "they're equal, don't render", and mixing that up produces frozen components with no error message at all; deep comparison is almost always more expensive than rendering, and when a comparator seems necessary, what's usually broken is the props contract. And above all: memo intervenes in only one of the four causes of render. It never blocks rendering caused by a component's own state, by a consumed context, or by a subscribed external store. A memo on ThemeButton is decoration.
Which gives us the rule: memoize when all three conditions hold — it re-runs often with the same props, running it is expensive, and its props either are or can be made stable — and don't memoize cheap components, volatile props, containers with children, or subtrees already isolated by structure. Every memo costs a comparison per render, memory, and an implicit contract the next developer will break without noticing: filling a project with memo makes it slower. The React Compiler automates this memoization when it's enabled, but it doesn't replace custom comparators, doesn't prevent renders caused by state or context, bails out on code that breaks React's rules, and doesn't save you the reasoning — which prop changed identity, and why — that's exactly what you need when something goes wrong.
There's still the module's most-cited debt outstanding: how to make onSelect the same function across renders, rates the same object, a context provider's value stop changing identity for no reason (the debt left open in 07-02), and sorting 2,000 bikes stop repeating when nothing has changed. All of that comes down to two hooks. The next lesson is The useMemo and useCallback Hooks.
React Course
Module 1: Getting Started with React
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
