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

  1. What memoization means
  2. React.memo: exact signature and behavior
  3. The render tree with and without memo
  4. Case study: BikeCard inside BikeList
  5. Why memo often does nothing
  6. Diagnosis: reproducing the failure and seeing it
  7. The custom comparator
  8. Inverted signature compared to shouldComponentUpdate
  9. What memo blocks and what it never blocks
  10. When to apply it and when not to
  11. The cost of memo
  12. memo and the React Compiler

  1. 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 calculation

This 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.5 is cheaper than looking something up in a Map: 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.

  1. React.memo: exact signature and behavior

import { memo } from 'react';

const Component = memo(function Component(props) {
  // …
});

memo 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:

  1. It retrieves the props from the previous render.
  2. It compares both props objects shallowly: it walks the top-level keys and compares each value with Object.is.
  3. If all of them are equal, it doesn't call the component function and reuses the element tree it produced last time.
  4. 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 renders

By 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.

  1. The render tree with and without memo

flowchart 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.

  1. Case study: BikeCard inside BikeList

Here'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:

  1. Move Intl.NumberFormat outside the component. This is the real optimization: it eliminates 2,000 object constructions per render without memoizing anything, and it keeps working even if memo never hits. It's technique 3 from 08-01 — avoid the work, don't just speed it up — applied to a constant.
  2. Wrap the export in memo. The project convention (export default for 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 write export default memo(({ bike }) => …), React DevTools will show the component as Anonymous and 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.

  1. Why memo often does nothing

Here'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 anyway

It 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: memo only works if every prop is a primitive, or is an object whose identity someone keeps stable. In CicloUrbano, bike is 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.

  1. 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.

  1. The custom comparator

memo accepts a second argument: a function that decides the comparison on your behalf.

memo(Component, (prevProps, nextProps) => boolean)

The contract is precise:

  • Return true if you consider the props equal → React doesn't render.
  • Return false if 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.district and 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.
  • children invalidates it almost every time. If the component receives children, 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, and undefined.
// ❌ 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, and freeDocks as primitives instead of the whole object removes the problem without a comparator and without memo.

  1. Inverted signature compared to shouldComponentUpdate

In 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.

  1. What memo blocks and what it never blocks

This 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.

  1. When to apply it and when not to

Apply memo when all three conditions hold at once:

  1. The component re-runs often with the same props. You've checked it, not assumed it.
  2. Running it is expensive: a large subtree, many elements, computations, formatting with Intl, or it's repeated hundreds of times in a list.
  3. 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

  1. The cost of memo

memo isn't free, and its cost has three parts:

  1. Comparison on every render of the parent. Walking the prop keys and calling Object.is for each one. It's cheap, but multiplied by 2,000 instances and by every keystroke, it stops being cheap.
  2. 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.
  3. Human cost. Every memo is an implicit contract: "this component's props must stay stable." Whoever adds an inline style={{...}} tomorrow will break that contract without knowing it, and memo will 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 memo makes the application slower, not faster. Every memo that 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.

  1. memo and the React Compiler

As 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:

  1. rates={{ hour: ..., deposit: 20 }} — object literal: new on every render even when pricePerHour doesn't change.
  2. 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.
  3. onConfirm={(data) => createBooking(data)} — inline arrow function: new on every render.
  4. 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

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved