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
useMemo: signature and exactly what it storesuseCallback: the special case ofuseMemo- The two hooks, compared
- Legitimate use 1: avoiding a genuinely expensive computation
- How to actually check whether a computation is expensive
- Legitimate use 2: preserving a value's identity
- Closing 08-02: the definitive
BikeList - Closing 07-02: a context provider's
value useEffectdependencies and Redux selectors- How to choose dependencies, and why the linter is right
- When a dependency changes too often
- When NOT to memoize
- Better alternatives than memoizing
- Concurrency hooks:
useTransitionanduseDeferredValue - The React Compiler here
useMemo: signature and exactly what it stores
useMemo: signature and exactly what it storesThree 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:
- On the first render, React runs the factory and stores the result along with the dependencies.
- On subsequent renders, it compares each dependency against the stored one using
Object.is. - If all of them match, it returns the stored value without running the factory.
- 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.
useCallback: the special case of useMemo
useCallback: the special case of useMemouseCallback 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]);
- 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.
- 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
mapthat does afindover the stations for every element — that's O(n × m) — and a sort withlocaleCompare, 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:
visibleis a new array on every render, so anymemoonBikeListis 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 |
- 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.
- 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.
- Closing 08-02: the definitive
BikeList
BikeListHere'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:
useCallbackwith[navigate]. React Router'snavigatefunction is stable by design, so both functions get created exactly once for the component's whole lifetime. NowObject.is(prevProps.onSelect, nextProps.onSelect)returnstrueand thememofrom 08-02 finally works.stationNamearrives already computed fromCataloguePage'suseMemo, instead of being resolved here with afindper card. Less work and one more primitive prop.- The handlers receive
idas an argument, no per-card function gets created. IfBikeCardneeded() => 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>'sonClickcouldn'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.
- Closing 07-02: a context provider's
value
valueThe 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
ThemeProviderre-runs for a reason unrelated to the theme — say, because its parent repainted —valueis the same object,Object.isreturnstrue, 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
valueshould stabilize it withuseMemo, and any functions it contains withuseCallback. 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.
useEffect dependencies and Redux selectors
useEffect dependencies and Redux selectorsIn 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 primitivesOption 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 |
- 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-depsWhen 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.
- 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 lifeThis 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.
- 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.
- 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"]
- Concurrency hooks:
useTransition and useDeferredValue
useTransition and useDeferredValueThe 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
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
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.
- 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-hookswith 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:
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:
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 primitiveI'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
- 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
