The previous lesson ended with a memo on BikeCard that doesn't save a single render, because BikeList creates new arrow functions on every run and the identity comparison always fails. That's the problem these two hooks solve, and it isn't the only one: module 7 left the same debt open in three other places — the value of context providers (07-02), Redux selector instances (07-05), and effect dependencies (05-02) — and they're all the same question: how do you make a value built inside a component stay the same object across renders. On top of that there's a different problem that useMemo also solves: keeping a genuinely expensive computation — sorting and filtering 2,000 bikes — from repeating when its inputs haven't changed. Telling those two uses apart is the most important thing in this lesson, because almost all misuse of memoization comes from mixing them up. At the end you'll also see React 18/19's concurrency hooks, useTransition and useDeferredValue, which attack the same symptom from a completely different angle: instead of doing less work, they reorganize when the work happens.

Contents

  1. useMemo: signature and exactly what it stores
  2. useCallback: the special case of useMemo
  3. The two hooks, compared
  4. Legitimate use 1: avoiding a genuinely expensive computation
  5. How to actually check whether a computation is expensive
  6. Legitimate use 2: preserving a value's identity
  7. Closing 08-02: the definitive BikeList
  8. Closing 07-02: a context provider's value
  9. useEffect dependencies and Redux selectors
  10. How to choose dependencies, and why the linter is right
  11. When a dependency changes too often
  12. When NOT to memoize
  13. Better alternatives than memoizing
  14. Concurrency hooks: useTransition and useDeferredValue
  15. The React Compiler here

  1. useMemo: signature and exactly what it stores

const memoizedValue = useMemo(() => calculateSomething(a, b), [a, b]);

Three parts:

  • The factory: a function with no arguments that returns the value. React calls it; you don't.
  • The dependency array: the values the result depends on.
  • The returned value: whatever the factory produced.

And the behavior, precisely:

  1. On the first render, React runs the factory and stores the result along with the dependencies.
  2. On subsequent renders, it compares each dependency against the stored one using Object.is.
  3. If all of them match, it returns the stored value without running the factory.
  4. If any of them differ, it runs the factory again and stores the new result and the new dependencies.

Two warnings that change how the code gets written:

The factory runs during render, so it must be pure: no requests, no timers, no DOM writes, no setState. That's what effects are for (05-02).

useMemo is an optimization, not a guarantee. React's documentation says this explicitly: React may discard memoized values whenever it needs to (for example, to free memory for off-screen components). Never write code whose correctness depends on the factory not running again. If you need something to genuinely happen only once, that's a useRef or an effect, not a useMemo.

  1. useCallback: the special case of useMemo

const handleBook = useCallback((id) => { /* … */ }, [user]);

useCallback stores the function itself, not the result of calling it. And this equivalence explains everything:

// These two lines do exactly the same thing
const f = useCallback((id) => book(id), [book]);
const f = useMemo(() => (id) => book(id), [book]);

useCallback(fn, deps) is useMemo(() => fn, deps). It exists as its own hook only because memoizing functions is common enough that the double arrow was awkward and error-prone.

The beginner mistake to avoid from day one:

// ❌ Calls the function and memoizes its RESULT
const total = useCallback(calculateTotal(hours), [hours]);

// ✅ Memoizes the FUNCTION
const calculate = useCallback(() => calculateTotal(hours), [hours]);

// ✅ Memoizes the RESULT — which is probably what you wanted
const total = useMemo(() => calculateTotal(hours), [hours]);

  1. The two hooks, compared

useMemo useCallback
What it stores The value returned by the factory The function you pass it
Signature useMemo(() => value, deps) useCallback(fn, deps)
When it recalculates When a dependency changes When a dependency changes
Equivalence — useMemo(() => fn, deps)
Reason 1: cost ✅ Its main reason ❌ Creating a function is dirt cheap
Reason 2: identity ✅ Objects, arrays, instances ✅ Its only reason
Typical use Filtering/sorting lists, context value, option objects Handlers passed to memoized components or to effect dependencies
The factory runs During render React never runs it: it only stores it

One nuance that puts the table in order: useCallback is never a cost optimization. Creating a function in JavaScript costs practically nothing. useCallback exists only to preserve identity. If nobody compares that function — it doesn't go to a memoized component, and it isn't a dependency of an effect or another hook — the useCallback is pure cost.

  1. Legitimate use 1: avoiding a genuinely expensive computation

Here's the CicloUrbano catalogue with its 2,000 bikes, filtering and sorting during render:

// src/pages/CataloguePage.jsx (unmemoized)
import { useSelector } from 'react-redux';
import { useSearchParams } from 'react-router';
import { useBikes, useStations } from '../queries/bikeQueries.js';

function CataloguePage() {
  const { data: bikes = [] } = useBikes();
  const { data: stations = [] } = useStations();
  const searchTerm = useSelector((state) => state.catalogue.searchTerm);
  const sort = useSelector((state) => state.catalogue.sort);
  const [searchParams] = useSearchParams();
  const type = searchParams.get('tipo') ?? 'todos';

  // This block runs on EVERY render, no matter what changed
  const visible = bikes
    .filter((b) => type === 'todos' || b.type === type)
    .filter((b) => b.model.toLowerCase().includes(searchTerm.toLowerCase()))
    .map((b) => ({
      ...b,
      stationName: stations.find((s) => s.id === b.stationId)?.name ?? '—'
    }))
    .sort((a, b) =>
      sort === 'precio' ? a.pricePerHour - b.pricePerHour : a.model.localeCompare(b.model)
    );

  return (
    <>
      <BikeSearch />
      <TypeSelector />
      <BikeList bikes={visible} />
    </>
  );
}

Two independent problems, and it's worth not mixing them up:

  • Cost: with 2,000 bikes there are two filter passes, a map that does a find over the stations for every element — that's O(n × m) — and a sort with localeCompare, one of the most expensive comparisons in JavaScript. Between 10 and 40 ms per render on a development machine, and three or four times that on a phone.
  • Identity: visible is a new array on every render, so any memo on BikeList is doomed to fail.

useMemo solves both at once:

const visible = useMemo(() => {
  // 1) Station index: turns the O(m) find into an O(1) lookup
  const stationNameById = new Map(stations.map((s) => [s.id, s.name]));
  // 2) Reusable collator: building an Intl.Collator per comparison is very expensive
  const collator = new Intl.Collator('en');
  const normalizedSearchTerm = searchTerm.trim().toLowerCase();

  return bikes
    .filter((b) => type === 'todos' || b.type === type)
    .filter((b) => b.model.toLowerCase().includes(normalizedSearchTerm))
    .map((b) => ({ ...b, stationName: stationNameById.get(b.stationId) ?? '—' }))
    .sort((a, b) =>
      sort === 'precio' ? a.pricePerHour - b.pricePerHour : collator.compare(a.model, b.model)
    );
}, [bikes, stations, searchTerm, type, sort]);

Pay attention to what just happened, because it's the underlying lesson: the work was reduced before memoizing it. The station Map turns a per-element linear search into a direct lookup, and the reused Intl.Collator avoids building a comparator on every sort call. Those two lines cut the cost more than the useMemo does, and they keep helping even if the dependencies change on every render. Memoization comes after, not instead of.

With the useMemo in place, here's the behavior table:

What changes Does it recalculate? Is that correct?
The user types in the search box Yes (searchTerm) Yes: the result depends on it
?tipo= changes in the URL Yes (type) Yes
The sort order changes Yes (sort) Yes
Query revalidates and returns the same data Yes (bikes is a new array) Unavoidable: Query replaces the reference
The Modal opens (the page's local state) No That's the win
The theme changes No Same
A new notice arrives No Same

  1. How to actually check whether a computation is expensive

"Expensive" isn't an opinion. It's measured, and there are two quick ways to check before opening the Profiler.

With console.time, wrapping the unmemoized computation:

const visible = useMemo(() => {
  console.time('filter and sort catalogue');
  const result = /* … the computation … */;
  console.timeEnd('filter and sort catalogue');
  return result;
}, [bikes, stations, searchTerm, type, sort]);

How to read the number that comes out on the console:

Measured duration Verdict
< 1 ms Don't memoize. The useMemo costs more than the computation
1 – 5 ms Grey area: depends on how often it happens and how much other work is in the same render
5 – 16 ms Memoizing probably pays off: you're closing in on a frame's budget
> 16 ms Memoize, and also review the algorithm: there's probably a find inside a map

The 16 ms reference comes from the fact that at 60 frames per second the browser has 16.7 ms per frame. Anything past that produces a visible stutter.

With CPU throttling from the browser's DevTools. In the Performance tab there's a CPU throttling selector: set it to 4× or 6×. It's the closest thing to a mid-range phone you have without buying one. A 4 ms computation on your laptop becomes 16–24 ms there, and what was irrelevant stops being irrelevant.

And the step almost nobody takes: remove the useMemo and measure again. If the number doesn't change noticeably, remove it for good. It's the "measure again" step from the 08-01 workflow, and it's what separates optimizing from decorating.

  1. Legitimate use 2: preserving a value's identity

The second use has nothing to do with the cost of the computation. Here the factory can be trivial — () => ({ user, isOperator }) — and useMemo is still essential, because what matters isn't how expensive the value is to build, but that something further down is going to compare it by identity.

Who compares it? Exactly these four:

Who compares What happens if the identity changes
memo on a child component The comparison fails and the component re-runs every time (08-02)
useEffect with that dependency The effect runs again: a repeated request, a restarted timer, a resubscription
A context provider (value) Every consumer re-runs, whether or not it uses the part that changed (07-02)
useSelector in Redux, or a memoized selector Repaints on every dispatched action, or memoization that never hits (07-05)

And there's a fifth, silent one: another useMemo or useCallback that has that value among its dependencies. An unstable identity cascades and invalidates all the memoization downstream of it. That's why identity problems get fixed from the top down: stabilize the value closest to the root first, and many of the ones below it fix themselves.

  1. Closing 08-02: the definitive BikeList

Here's the code that 08-02 left unfinished.

// src/components/BikeList.jsx (definitive version)
import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import BikeCard from './BikeCard.jsx';
import styles from './BikeList.module.css';

function BikeList({ bikes }) {
  const navigate = useNavigate();

  // navigate is stable: React Router guarantees its identity across renders.
  // With [navigate] as the dependency, these functions are created ONCE.
  const handleSelect = useCallback(
    (id) => navigate(`/bicicletas/${id}`),
    [navigate]
  );

  const handleBook = useCallback(
    (id) => navigate(`/reservas/nueva?bicicleta=${id}`),
    [navigate]
  );

  return (
    <ul className={styles.grid}>
      {bikes.map((bike) => (
        <li key={bike.id}>
          <BikeCard
            bike={bike}
            stationName={bike.stationName}
            onSelect={handleSelect}
            onBook={handleBook}
          />
        </li>
      ))}
    </ul>
  );
}

export default BikeList;

Three decisions and their reasons:

  1. useCallback with [navigate]. React Router's navigate function is stable by design, so both functions get created exactly once for the component's whole lifetime. Now Object.is(prevProps.onSelect, nextProps.onSelect) returns true and the memo from 08-02 finally works.
  2. stationName arrives already computed from CataloguePage's useMemo, instead of being resolved here with a find per card. Less work and one more primitive prop.
  3. The handlers receive id as an argument, no per-card function gets created. If BikeCard needed () => onBook(bike.id), that arrow would be created inside the card, which is where it belongs: it's a function the card hands to a DOM element, and a <button>'s onClick couldn't care less about its identity.

This is the scenario where memo and useCallback work: together, in a long list, backed by a measurement that justifies it. Neither one does anything on its own.

  1. Closing 07-02: a context provider's value

The debt module 7 explicitly left open. The problem, recapped in one line: a provider's value is an object literal created on every render, so every render of the provider re-runs every one of its consumers, even when the content is identical.

// ❌ The problem
export function ThemeProvider({ children }) {
  const [theme, setTheme] = useLocalStorage('tema', 'claro');

  // NEW object on every render → every consumer re-runs
  const value = { theme, toggleTheme: () => setTheme((t) => (t === 'claro' ? 'oscuro' : 'claro')) };

  return <ThemeContext value={value}>{children}</ThemeContext>;
}
// ✅ The definitive version of src/contexts/ThemeContext.jsx
import { createContext, useContext, useMemo, useCallback, useEffect } from 'react';
import { useLocalStorage } from '../hooks/useLocalStorage.js';

const ThemeContext = createContext(null);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useLocalStorage('tema', 'claro');

  useEffect(() => {
    document.documentElement.dataset.theme = theme;
  }, [theme]);

  // 1) The function, stable: useState's setTheme is stable by React's contract
  const toggleTheme = useCallback(() => {
    setTheme((current) => (current === 'claro' ? 'oscuro' : 'claro'));
  }, [setTheme]);

  // 2) The object, stable as long as the theme doesn't change
  const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);

  return <ThemeContext value={value}>{children}</ThemeContext>;
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (context === null) {
    throw new Error('useTheme must be used inside <ThemeProvider>');
  }
  return context;
}

What it achieves, and what it doesn't, precisely:

  • It achieves: if ThemeProvider re-runs for a reason unrelated to the theme — say, because its parent repainted — value is the same object, Object.is returns true, and React doesn't notify a single consumer.
  • It doesn't achieve: when the theme does change, every consumer re-runs, including the ones that only use toggleTheme. That's what splitting contexts into state and actions from 07-02 is for — a structural, complementary solution.

Applied to NoticesContext, where that split already exists, the result is the complete pattern:

// src/contexts/NoticesContext.jsx (fragment with the full memoization)
export function NoticesProvider({ children }) {
  const [notices, setNotices] = useState([]);

  const dismissNotice = useCallback((id) => {
    setNotices((previous) => previous.filter((notice) => notice.id !== id));
  }, []);

  const showNotice = useCallback((tone, text, duration = 5000) => {
    const id = `notice-${crypto.randomUUID().slice(0, 8)}`;
    setNotices((previous) => [...previous, { id, tone, text }]);
    if (duration > 0) setTimeout(() => dismissNotice(id), duration);
    return id;
  }, [dismissNotice]);

  // The ACTIONS never change: their consumers never re-run
  const actions = useMemo(
    () => ({ showNotice, dismissNotice }),
    [showNotice, dismissNotice]
  );

  return (
    <NoticesActionsContext value={actions}>
      <NoticesStateContext value={notices}>
        {children}
      </NoticesStateContext>
    </NoticesActionsContext>
  );
}

The combined result is exactly what 07-02 was after: a component like BookingForm, which only calls showNotice and never reads the list, never re-runs because of notices. Before, it used to re-run every time a notice appeared or disappeared anywhere in the application.

Operating rule, now justified: every provider that publishes an object literal as value should stabilize it with useMemo, and any functions it contains with useCallback. It's one of the very few places where memoizing is the default choice rather than a premature optimization, because the cost of skipping it propagates to the whole application.

  1. useEffect dependencies and Redux selectors

In effects (picking 05-02 back up): a function or an object in a useEffect's dependency array causes the effect to run again on every render.

// ❌ Request loop: options is a new object every render
function IncidentsPanel({ stationId }) {
  const options = { stationId, includeClosed: false };

  useEffect(() => {
    loadIncidents(options).then(setIncidents);
  }, [options]);              // ALWAYS changes → a request on every render
}

// ✅ Option A: stabilize the object
const options = useMemo(() => ({ stationId, includeClosed: false }), [stationId]);

// ✅ Option B, better: don't create the object outside the effect
useEffect(() => {
  loadIncidents({ stationId, includeClosed: false }).then(setIncidents);
}, [stationId]);              // only primitives

Option B is almost always preferable, and that's the lesson: when an unstable dependency causes problems in an effect, the first question isn't "how do I memoize it?" but "why is it outside the effect?"

In Redux selectors (picking 07-05 back up): an inline selector that builds a value returns a new reference on every call, and useSelector compares by identity, so the component repaints on every action dispatched in the application.

// ❌ New object on every selector run
const { active, total } = useSelector((state) => ({
  active: state.bookings.ids.filter((id) => state.bookings.entities[id].status === 'activa'),
  total: state.bookings.ids.length
}));

// ✅ Memoized selector in the slice (createSelector), read without building anything
const summary = useSelector(selectBookingsSummary);

// ✅ Selector factory with an argument, with its instance stabilized
const bookingsSelector = useMemo(createUserBookingsSelector, []);
const bookings = useSelector((state) => bookingsSelector(state, userId));

The distinction between the three layers of memoization now coexisting in CicloUrbano:

Layer Tool Scope What it avoids
Store createSelector Global, shared by every component Recalculating derivations of the Redux state
Component useMemo A single component instance Recalculating on every render of that component
Server Query's staleTime Global, per query key Re-fetching data from the server

  1. How to choose dependencies, and why the linter is right

The rule is exact and admits no exceptions: the dependency array must contain every reactive value the factory reads. A reactive value is anything declared inside the component that could change between renders: props, state, context values, and any variable derived from them.

function BookingPanel({ bike, discount }) {
  const [hours, setHours] = useState(1);
  const { user } = useSession();

  const amount = useMemo(
    () => bike.pricePerHour * hours * (1 - discount),
    [bike.pricePerHour, hours, discount]   // the three values it reads, not one more
  );
  // `user` is NOT a dependency: the factory doesn't read it
}

What happens if you get it wrong, in each direction:

Mistake Consequence Severity
A dependency is missing The value freezes with stale data. The screen lies Serious: it's a correctness bug
There's an extra dependency Recalculates more than needed. Only the optimization is lost Minor
Empty array [] on a value that depends on props Frozen forever Very serious
No array at all (omitted) Always recalculates: the useMemo does nothing Minor, but it's dead code

That's why eslint-plugin-react-hooks's exhaustive-deps rule isn't optional in a serious project, and why silencing it with a comment almost always hides a design problem:

// ❌ The most reliable warning sign in the React ecosystem
// eslint-disable-next-line react-hooks/exhaustive-deps

When you feel the urge to write that line, the real problem is usually one of these three: the factory is doing something that belongs in an effect, the dependency should be stabilized in the parent component, or the value shouldn't be in state at all.

  1. When a dependency changes too often

This is the hard case: you've set the right dependencies, and one of them changes on every render, so the memoization never hits. Four ways out, from best to worst:

1. Move anything that depends on nothing outside the component.

// ✅ Constants and pure functions: module scope
const ALLOWED_TYPES = ['urbana', 'electrica', 'carga'];
const EURO_FORMATTER = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' });

function calculateAmount(pricePerHour, hours) {
  return pricePerHour * hours;
}

function BookingPanel({ bike }) {
  // No useMemo, no useCallback: these values are never created more than once
}

By far the best solution: zero memoization, zero dependencies, zero risk. Before memoizing anything, ask yourself whether it actually depends on something in the component.

2. Move the function inside whoever uses it. If handleSubmit is only used by one effect, define it inside the effect and it disappears from the dependency array (05-02).

3. Use useReducer instead of several useState calls. The dispatch function returned by useReducer is stable by contract: React guarantees it never changes between renders. That lets you pass it straight to memoized components with no useCallback at all.

// The booking form's state, with a stable dispatch
const [draft, dispatch] = useReducer(draftReducer, INITIAL_DRAFT);

// `dispatch` is stable: memo works with no useCallback
<BookingForm draft={draft} onDispatch={dispatch} />

The same holds for useState's setX functions and for react-redux's dispatch: all of them are stable and never need useCallback.

4. Use a ref for values that shouldn't trigger a recalculation. This is the last resort, and it takes care — only for values read inside handlers or effects, never during render:

const onBookRef = useRef(onBook);
useEffect(() => { onBookRef.current = onBook; });   // always up to date

const handleClick = useCallback((id) => {
  onBookRef.current(id);      // reads the latest version without depending on it
}, []);                        // stable identity for life

This pattern — the "effect event" — is useful, but it breaks the explicit data flow and makes the code harder to follow. Use it when the previous three options don't work, not before.

  1. When NOT to memoize

Don't memoize Why
Primitive values (const total = price * hours) Multiplying is cheaper than checking the memoization
Trivial computations (array.length, a concatenation, a Boolean()) The hook costs more than the computation
Filters over short arrays (fewer than ~100 elements and no per-element work) Iterating over 20 objects is statistical noise
Functions that only go to a DOM element (onClick on a <button>) The DOM doesn't care about a function's identity
Functions only the component itself uses Nobody compares them
Props of a component that isn't memoized There's no comparison to take advantage of
Components that render once There are no repeated renders to save
"Just in case" It's the literal definition of premature optimization

The most common case, and the most useless of all:

// ❌ useCallback with nobody comparing it
function SignInPage() {
  const handleSubmit = useCallback((event) => {
    event.preventDefault();
    signIn(user);
  }, [user]);

  return <form onSubmit={handleSubmit}>…</form>;   // a DOM <form>: it compares nothing
}

Here useCallback adds a dependency array to maintain, a comparison on every render, and memory, in exchange for exactly nothing. The correct version is a plain function.

And the honest accounting of the cost, rarely made explicit: every useMemo or useCallback means a hook call, an array created on every render, a comparison per dependency, and memory held for as long as the component lives. It's small, but it isn't zero, and multiplied by hundreds of unnecessary uses, it's measurable.

  1. Better alternatives than memoizing

Before writing a useMemo, run through this table. Every row is a technique that solves the same problem with no dependencies to maintain.

Situation Instead of memoizing Why it's better
Some state repaints a large subtree Push the state down (08-01) Eliminates the render instead of skipping it. Less code
A stateful container wraps expensive content Pass children (08-01) Structural isolation, no comparison, no memory
The value depends on nothing in the component Move it outside the component Created once for the whole application
Several useState calls with handlers passed down useReducer dispatch is stable by contract
An expensive computation over Redux state createSelector in the slice Memoization shared by every component
An expensive computation over server data useQuery's select Computed when the data arrives, not on every render
A very long list Paginate or virtualize (08-01) Reduces the real work instead of caching it
The result depends only on the identifier A Map index built once Changes the algorithm's complexity
A filter that's blocking typing useDeferredValue (section 14) Reorders the work instead of avoiding it

The complete decision tree, summarizing sections 4 through 13:

flowchart TD
    A["I'm about to write a useMemo<br/>or a useCallback"] --> B{"Does it depend on<br/>something in the component?"}
    B -->|No| C["Move it OUTSIDE the component<br/>as a constant or a pure module function"]
    B -->|Yes| D{"Why do I want<br/>to memoize?"}

    D -->|"The computation is expensive"| E{"Have I measured it?<br/>console.time + 4x CPU"}
    E -->|No| F["Measure first.<br/>Under 1 ms: don't memoize"]
    E -->|"Yes, over 5 ms"| G{"Can I reduce the<br/>work instead of caching it?"}
    G -->|Yes| H["Map index, reused Collator,<br/>pagination, useQuery's select"]
    G -->|No| I["useMemo is justified"]

    D -->|"Something compares<br/>the identity"| J{"Who compares it?"}
    J -->|Nobody| K["Don't memoize:<br/>pure cost"]
    J -->|"A component with memo"| L["useCallback / useMemo,<br/>and check memo is actually in place"]
    J -->|"A useEffect dependency"| M["Better: move the value<br/>INSIDE the effect"]
    J -->|"A context's value"| N["useMemo + useCallback:<br/>the default choice here"]
    J -->|"A Redux selector"| O["createSelector in the slice"]

  1. Concurrency hooks: useTransition and useDeferredValue

The two hooks above try to do less work. The concurrency hooks do something different: they let React interrupt and postpone non-urgent work so the interface keeps responding. React can start rendering an update marked as non-urgent, abandon it halfway through if user input arrives, handle that input, and pick the render back up afterward.

useTransition

const [isPending, startTransition] = useTransition();

Returns a boolean — "there's a transition in progress" — and a function to wrap non-urgent updates.

// src/components/TypeSelector.jsx
import { useTransition } from 'react';
import { useSearchParams } from 'react-router';

function TypeSelector() {
  const [searchParams, setSearchParams] = useSearchParams();
  const [isPending, startTransition] = useTransition();
  const type = searchParams.get('tipo') ?? 'todos';

  function handleChange(event) {
    const newType = event.target.value;

    // Not urgent: repainting 2,000 cards can wait, and can be interrupted
    startTransition(() => {
      setSearchParams(newType === 'todos' ? {} : { tipo: newType });
    });
  }

  return (
    <select value={type} onChange={handleChange} aria-busy={isPending}>
      <option value="todos">All types</option>
      <option value="urbana">Urban</option>
      <option value="electrica">Electric</option>
      <option value="carga">Cargo</option>
    </select>
  );
}

What changes: without the transition, picking "Electric" freezes the <select> while React filters and paints the catalogue. With it, the dropdown responds instantly, isPending lets you dim the list while it recalculates, and if the user picks another option again, React abandons the render halfway through and starts the new one.

Important requirement: the update has to go inside startTransition and be synchronous. And it doesn't work with controlled text fields: an <input>'s value is an urgent update by definition, and marking it as a transition produces a field that feels broken.

useDeferredValue

const deferredValue = useDeferredValue(value);

Returns a copy of the value that deliberately lags behind. React renders first with the old value (fast, urgent), and then, in the background and interruptibly, with the new one.

// src/pages/CataloguePage.jsx (fragment)
function CataloguePage() {
  const searchTerm = useSelector((state) => state.catalogue.searchTerm);
  const deferredSearchTerm = useDeferredValue(searchTerm);
  const isStale = searchTerm !== deferredSearchTerm;

  const visible = useMemo(
    () => filterAndSort(bikes, stations, deferredSearchTerm, type, sort),
    [bikes, stations, deferredSearchTerm, type, sort]
  );

  return (
    <>
      <BikeSearch />
      <div style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
        <BikeList bikes={visible} />
      </div>
    </>
  );
}

The combination of useDeferredValue + useMemo + memo is the complete pattern: the deferred value reduces how many times the calculation reruns during fast typing, the useMemo avoids recalculating when nothing relevant changed, and BikeCard's memo avoids re-running cards that haven't moved. None of them replaces the others.

The three, compared

useDebounce (05-06) useTransition useDeferredValue
Problem it solves Work that happens too often Urgent work mixed with non-urgent work A value that's expensive to consume
Mechanism Timer: waits for silence Marks the update as interruptible Renders in two passes, the second one deferrable
Delay Fixed, you choose it (300 ms) None: adapts to actual load No fixed delay: adapts
Interruptible No: once it runs, it blocks Yes Yes
Avoids server requests Yes, its main strength No No
Progress indicator Manual isPending Compare the value and the deferred value
Where it goes On the producer of the value On whoever triggers the update On whoever consumes the value
In CicloUrbano Search box → request to json-server TypeSelector, sort change Local filtering of the catalogue

The practical takeaway, more useful than the table: they aren't alternatives, they're complementary. In the definitive BikeSearch, both coexist: useDebounce so a request doesn't fire on every keystroke — that's network traffic, and a timer is the right tool for it — and useDeferredValue over the term so that locally filtering the 2,000 bikes doesn't block the field between one request and the next.

  1. The React Compiler here

React 19's React Compiler is, in essence, an automatic generator of useMemo and useCallback. In a compiled project:

Work Does the compiler do it?
Stabilizing handleSelect and handleBook in BikeList ✅ Yes, without useCallback
Stabilizing ThemeProvider's value object ✅ Yes, if it's built inside the component itself
Avoiding repeated filtering and sorting when the inputs don't change ✅ Yes, it memoizes the expression
Making the first sort of 2,000 bikes fast ❌ No. The algorithm is still yours
Replacing the station Map with a find per element ❌ No: it doesn't rewrite algorithms
Knowing that a function imported from another module is stable ❌ No: it doesn't reason outside the component
Deciding between useTransition or useDeferredValue ❌ No: those are user-experience decisions
Fixing a useEffect that fires too often because of an external dependency ❌ No
Optimizing a component that breaks React's rules ❌ No: it bails out on it entirely, silently

How to check what it did. The compiler isn't a black box:

  • React DevTools marks optimized components with a ✨ Memo ✨ badge next to their name. It's the fastest check: if your critical component doesn't have it, the compiler skipped it, and you need to find out why.
  • The Profiler (08-05) is still the definitive test: measure the same interaction with and without the compiler in a production build and compare.
  • The linter (eslint-plugin-react-hooks with the compiler's rules) tells you in advance which components will be skipped, and why.

And the part that's still yours, summed up in one sentence: the compiler decides when to reuse a value; you decide what value is worth computing, with what algorithm, and whether that work should even be happening in the first place.

Common Mistakes and Tips

Mixing up the two legitimate uses. "I'm memoizing because it's expensive" and "I'm memoizing because something compares the identity" are different reasons and lead to different decisions. A useMemo on a trivial computation is useless... unless the result is an object going to a memoized component, in which case it's essential. Always be clear on which of the two reasons applies to you.

useCallback on functions nobody compares. This is the most common use, and the most useless one. If the function goes to a <button>'s onClick, remove it.

Silencing exhaustive-deps. A useMemo with incomplete dependencies produces stale data on screen: a correctness bug, not a performance one. If the linter is bothering you, the problem is in the design.

Memoizing inside a .map(). Hooks can't be called in loops (the rule from 04-04). If every element needs memoization, the memoization goes inside the child component, not in the parent.

Believing useMemo guarantees single execution. React may discard the stored value. Don't put anything there that your code's correctness depends on.

Using useTransition on a controlled text field. An <input>'s value is urgent by definition; deferring it produces a field that feels broken. What gets deferred is its consumption, with useDeferredValue.

Tip: fix the algorithm first, memoize second. The station Map and the reused Intl.Collator cut the cost more than any useMemo, and they keep working even when the dependencies genuinely change.

Tip: stabilize from the top down. An unstable identity invalidates all the memoization below it. Fix the provider's value and the root component's options object before touching the leaves.

Tip: memoize the whole package, not the pieces. Instead of three useMemo calls for three fields, use a single one for the object that contains them, if that's what's actually going to be compared.

Exercises

Exercise 1. For each use, say whether the hook is necessary, is unnecessary, or is written incorrectly, and fix the last two cases.

// a)
const total = useMemo(() => hours * bike.pricePerHour, [hours, bike.pricePerHour]);

// b)
const sortedBikes = useMemo(
  () => [...bikes].sort((x, y) => x.pricePerHour - y.pricePerHour),
  [bikes]
);
// bikes has 2,000 elements and is passed to <BikeList> (memoized)

// c)
const handleClose = useCallback(() => setModalOpen(false), []);
// Usage: <Modal onClose={handleClose} />  ← Modal is NOT memoized

// d)
const filtered = useCallback(bikes.filter((b) => b.status === 'disponible'), [bikes]);

// e)
const value = useMemo(() => ({ user, signIn, signOut }), []);
// Usage: <SessionContext value={value}>

Exercise 2. StationDetailPage keeps re-fetching incidents from json-server on every render, so the tab flickers nonstop. Diagnose the exact cause and propose two solutions: one with memoization and one without. Say which one you'd pick and why.

function StationDetailPage() {
  const { estacionId } = useParams();
  const [incidents, setIncidents] = useState([]);
  const filters = { stationId: estacionId, status: 'abierta', sort: 'fecha' };

  useEffect(() => {
    let ignore = false;
    loadIncidents(filters).then((data) => { if (!ignore) setIncidents(data); });
    return () => { ignore = true; };
  }, [filters]);

  return <IncidentsTab incidents={incidents} />;
}

Exercise 3. CicloUrbano's BikeSearch has to satisfy three requirements at once: (1) the field responds instantly to every keystroke; (2) it doesn't fire a request to json-server on every keystroke; (3) locally filtering the 2,000 bikes doesn't block typing. Write the version that satisfies all three, using useDebounce, useDeferredValue, and useMemo where they belong, and justify in a table which requirement each tool covers.

Solutions

Solution 1.

a) Unnecessary. A multiplication of two numbers. The useMemo costs more than the computation, and the result is a primitive compared by value. Fix: const total = hours * bike.pricePerHour;.

b) Necessary, twice over. Sorting 2,000 elements is a computation with real cost (legitimate use 1) and the result is an array whose identity matters for BikeList's memo (legitimate use 2). It's also written correctly: [...bikes] copies before sorting, because sort mutates the original array, and mutating the Query cache's data would be a serious bug.

c) Unnecessary. Modal isn't memoized, so nobody compares onClose: it re-runs regardless. Fix: a plain function, const handleClose = () => setModalOpen(false);. (If Modal were wrapped in memo tomorrow, the useCallback would become necessary.)

d) Written incorrectly. useCallback stores functions, and here it's given the result of a .filter(): filtered ends up being an array, not a function, and the memoization doesn't do what it looks like it does. Fix:

const filtered = useMemo(() => bikes.filter((b) => b.status === 'disponible'), [bikes]);

e) Written incorrectly, and it's the most dangerous of the five mistakes. The dependency array is empty, so value freezes with the user from the first render: when someone signs in, no context consumer will find out. A correctness bug disguised as an optimization. Fix:

const value = useMemo(
  () => ({ user, signIn, signOut }),
  [user, signIn, signOut]
);

With signIn and signOut in turn stabilized with useCallback in the provider.

Solution 2. Exact cause: filters is an object literal created on every render. The effect has it as a dependency, Object.is always returns false, the effect runs, setIncidents triggers a render, the render creates another filters object... and the cycle never ends. It's a request loop, not a random flicker.

Solution A, with memoization:

const filters = useMemo(
  () => ({ stationId: estacionId, status: 'abierta', sort: 'fecha' }),
  [estacionId]
);

Solution B, without memoization: the object has no reason to exist outside the effect.

useEffect(() => {
  let ignore = false;
  loadIncidents({ stationId: estacionId, status: 'abierta', sort: 'fecha' })
    .then((data) => { if (!ignore) setIncidents(data); });
  return () => { ignore = true; };
}, [estacionId]);       // just a primitive

I'd pick B, for three reasons: it doesn't add a hook or a dependency array to maintain, its only dependency is a primitive (impossible to break by identity), and it expresses the intent better — "when the station changes, reload." The general rule: when an unstable dependency breaks an effect, the first question is why it's outside the effect.

And the truly correct solution in today's CicloUrbano, after 07-06: this shouldn't be a useEffect at all. It's server state, and it belongs in a query with its own hierarchical key:

const { data: incidents = [] } = useQuery({
  queryKey: keys.stations.incidents(estacionId),
  queryFn: () => loadIncidents({ stationId: estacionId, status: 'abierta', sort: 'fecha' })
});

Solution 3.

// src/components/BikeSearch.jsx (definitive version)
import { useState, useEffect, useDeferredValue } from 'react';
import { useDispatch } from 'react-redux';
import { useDebounce } from '../hooks/useDebounce.js';
import { searchTermChanged } from '../store/catalogueSlice.js';

function BikeSearch() {
  const [text, setText] = useState('');              // (1) responds to every keystroke
  const debouncedSearchTerm = useDebounce(text, 300); // (2) one request per pause
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(searchTermChanged(debouncedSearchTerm));
  }, [debouncedSearchTerm, dispatch]);

  return (
    <input
      type="search"
      value={text}
      onChange={(event) => setText(event.target.value)}
      aria-label="Search bikes by model"
    />
  );
}
// src/pages/CataloguePage.jsx (fragment)
const searchTerm = useSelector((state) => state.catalogue.searchTerm);
const deferredSearchTerm = useDeferredValue(searchTerm);       // (3) doesn't block typing
const isStale = searchTerm !== deferredSearchTerm;

const visible = useMemo(                                        // (3) and identity stability
  () => filterAndSort(bikes, stations, deferredSearchTerm, type, sort),
  [bikes, stations, deferredSearchTerm, type, sort]
);
Tool Requirement it covers Why nothing else works
Local useState in the search box (1) The field responds to every keystroke It's pure interface state; no other tool should get involved here
useDebounce (2) One request per pause, not per keystroke Only a timer avoids network traffic; neither useDeferredValue nor useTransition reduce requests
useDeferredValue (3) Filtering doesn't block typing It's interruptible and has no fixed delay; a second useDebounce would add perceived latency
useMemo (3) Not repeating the computation, and stabilizing visible Filtering 2,000 elements is expensive and its result feeds a memoized component
memo on BikeCard (3) Not re-running cards that haven't changed Closes the chain: without it, the new array would repaint all 2,000 anyway

Conclusion

useMemo(factory, dependencies) stores the value the factory produces and only runs it again when a dependency changes according to Object.is; useCallback(function, dependencies) is literally useMemo(() => function, deps) and stores the function. The factory runs during render, so it must be pure, and memoization is an optimization, not a guarantee: React may discard the stored value, and nothing your code's correctness depends on can lean on it.

What organizes everything else are the two legitimate uses, which need to stay separate at all times. The first is avoiding a genuinely expensive computation: filtering, indexing, and sorting 2,000 bikes with Intl.Collator, measured beforehand with console.time and 4× CPU throttling, guided by the 16 ms frame reference and the discipline of removing the useMemo and measuring again. And the underlying lesson: the station Map and the reused collator cut the cost more than the useMemo itself, because they change the algorithm instead of caching the result. The second use is preserving a value's identity when something compares it: a component wrapped in memo, a useEffect dependency array, a context provider's value, or a Redux selector.

That closes two debts from the course. The one from 08-02: BikeList stabilizes handleSelect and handleBook with useCallback([navigate]), and only then does BikeCard's memo start doing anything — the two together, never apart. And the one from 07-02: ThemeProvider publishes a value stabilized with useMemo and a toggleTheme stabilized with useCallback, and NoticesProvider combines that memoization with the split into state and action contexts, so that a component that only calls showNotice never re-runs because of notices.

On dependencies, the rule allows no nuance: every reactive value the factory reads, not one more, not one less. An extra dependency only costs performance; a missing one produces stale data on screen, which is a correctness bug. That's why exhaustive-deps is right almost every time, and silencing it is the most reliable warning sign in the ecosystem. And when a dependency changes too often, there are better ways out than pushing through: move whatever depends on nothing outside the component, move the function inside the effect, use useReducer because dispatch is stable by contract — just like setState and Redux's dispatch — and, as a last resort, an always-current ref.

You also know when not to memoize: primitives, trivial computations, short lists, functions that only go to a DOM element, props of unmemoized components, and "just in case", which is the literal definition of premature optimization. And what to do instead: push the state down, pass children, move constants outside, createSelector, useQuery's select, paginate or virtualize. Finally, the concurrency hooks, which attack the same symptom from another angle: useTransition marks the update you trigger — a type or sort change in the catalogue — as interruptible, useDeferredValue defers the consumption of an expensive value with no fixed delay, and neither replaces useDebounce, still the only one of the three that avoids server requests. In the definitive search box, all three coexist. The React Compiler, for its part, writes the mechanical memoization for you and flags it with a ✨ badge in DevTools, but it doesn't improve your algorithm, doesn't reason about imported values, and doesn't decide for you what work should be happening in the first place.

With this, the CicloUrbano catalogue no longer repeats unnecessary work when the user interacts with it. What remains is the other problem 07-06 flagged, one no amount of memoization touches: the browser downloads all of the application's JavaScript — the workshop, the station detail view, the booking form, the charting library — before it shows a single bike. The next lesson is Code Splitting and Lazy Loading.

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