Module 7 ended with an uncomfortable diagnosis: CicloUrbano knows where every piece of data lives and why, but it doesn't go fast. There are components repainting for no reason, calculations redone on every render, and a bundle the browser downloads in full before it shows the first bike. The natural temptation at this point is to open the editor and start sprinkling memo and useMemo across the project until "it feels better." That temptation is exactly what this lesson is here to defuse. Optimizing without measuring isn't optimizing: it's adding complexity blindly and hoping it pays off. Before touching a single line you need to know what "slow" means to the user, why a component re-runs, what's genuinely expensive and what isn't, and — most important of all — that most performance problems in a React app are best solved without memoizing anything. This lesson is the big picture and the method: the techniques that almost always pay off more than memoization, the role of React 19's React Compiler, and the workflow that structures the whole module. The memoization tools arrive in the lessons that follow, and they arrive better prepared thanks to this one.
Contents
- The rule that governs the module: measure first
- The real cost of premature optimization
- What "slow" means in an interface: the metrics that matter to the user
- Why a component re-runs
- The ecosystem's most expensive misunderstanding
- Technique 1: lower state to the component that uses it
- Technique 2: pass
childrento isolate a subtree - Technique 3: derive during render instead of storing and syncing
- Technique 4: stable keys in lists
- Technique 5: delay and throttle text input
- Technique 6: paginate and virtualize long lists
- Technique 7: take work off the main thread
- React 19's React Compiler: what it automates and what it doesn't
- Performance budget and workflow
- Map of the rest of the module
- The rule that governs the module: measure first
Don't optimize what you haven't measured. If you can't point to a number that's getting worse, you don't have a performance problem — you have a suspicion.
This rule sounds like a manual platitude and is an engineering decision with very concrete consequences. The reasons intuition fails at web performance are systematic:
- Your machine isn't the user's. You develop on a powerful laptop, the app on
localhost, with no network latency. CicloUrbano's user opens the catalogue on a mid-range phone, on patchy 4G, while walking to the station. - Development mode lies. Vite's dev server serves unbundled modules, React logs warnings and debug information, and
StrictModeruns every component twice. A render that takes 18 ms in development can take 4 ms in production. - What feels slow and what is slow rarely match. A 300 ms render on a button click is a perceived disaster; the same amount of work spread across a background
setTimeoutgoes unnoticed by anyone. - Bottlenecks hide. The component you suspect is almost never the culprit. In CicloUrbano, the reason typing in the search box feels sluggish isn't the
<input>— it's the cards repainting behind it.
The practical consequence is that the correct order of work always starts with lesson 08-05 (the Profiler), even though it's the last one in the module. This module is studied in order because you need the tools to understand what the Profiler shows, but it's applied in reverse order: measure, locate, fix, measure again.
- The real cost of premature optimization
When someone says premature optimization is expensive, they usually stay abstract. Here are the real costs, one by one.
| Cost | What it involves | Example in CicloUrbano |
|---|---|---|
| Readability | The code stops saying what it does and starts saying how it does it fast | const onBook = useCallback((id) => …, [user, showNotice, createBooking]) versus a plain three-line function |
| Memory | Every memoized value gets stored. Memoizing 2,000 cards means storing 2,000 comparisons and 2,000 results | A memo on every leaf component of a long catalogue |
| Added work | Comparing props costs too. If the component is cheap, comparing is more expensive than just re-running it | memo on StatusBadge, which only paints a <span> |
| Dependency bugs | An incomplete dependency array freezes a stale value; an excessive one cancels the memoization silently | A useMemo that forgets sort and keeps showing the catalogue sorted the way it used to be |
| False sense of a job done | The issue gets closed without touching the real problem | Memoizing the whole catalogue when what's actually wrong is 900 KB of initial JavaScript |
By far the most serious is the fourth one. An unnecessary memo slows things down a little; a useMemo with the wrong dependencies produces incorrect data on screen, and that failure is intermittent, hard to reproduce, and usually gets discovered by a user.
A performance bug makes the app slow. A memoization bug makes the app lie.
- What "slow" means in an interface: the metrics that matter to the user
"Slow" isn't a feeling: it's a set of measurable quantities. These are the ones the industry has settled on (the so-called Web Vitals), what they mean, and — the key fact for this module — how much React can do about each one.
| Metric | What the user experiences | Reasonable target | Does it depend on React? |
|---|---|---|---|
| FCP (First Contentful Paint) | How long it takes for anything to show up on the blank screen | < 1.8 s | Partly: mostly bundle size (08-04) and the server |
| LCP (Largest Contentful Paint) | How long it takes for the main content to appear: the bike list | < 2.5 s | Partly: bundle, data, and the cost of the first render |
| TTI (Time To Interactive) | When the page actually responds to a click | < 3.8 s | Yes: JavaScript downloaded, parsed, and executed |
| INP (Interaction to Next Paint) | How long the screen takes to react to a click or keystroke | < 200 ms | Yes, a lot: this module's own metric |
| CLS (Cumulative Layout Shift) | How much the layout "jumps" while loading | < 0.1 | Yes: badly designed loading indicators and skeletons (08-04) |
| TBT (Total Blocking Time) | How long the browser can't attend to the user | < 200 ms | Yes: long renders, calculations during render |
Two conclusions from this table structure the whole module:
- INP and TBT are React's territory. When typing in
BikeSearchtakes 400 ms to show up, that's INP, and it gets fixed with memoization,useDeferredValue, or virtualization (08-02, 08-03). - FCP and LCP are mostly the bundler's territory. No
memofixes the browser having to download 1.2 MB of JavaScript before painting the catalogue: code splitting does (08-04).
And a third, uncomfortable one: there are causes of slowness that aren't React's at all and no technique in this module will touch — station photos that aren't compressed or sized, a json-server query that returns all 2,000 bikes unpaginated, fonts that block painting, or a cascading request that waits on another one. Before memoizing anything, make sure the problem is where you think it is.
- Why a component re-runs
01-05 established React's cycle: render → diff → commit. Now comes the part that model left implicit: what makes React call a component's function again. There are only four causes, and there's no fifth.
| Cause | Description | Example |
|---|---|---|
| 1. Its own state changes | A setState (or a dispatch) with a value that differs per Object.is |
BikeSearch on typing a letter |
| 2. An ancestor re-renders | React re-runs the subtree the parent returns, whether props changed or not | BikeCard when CataloguePage repaints |
| 3. A context it consumes changes | Every consumer of that context re-runs, even if it only uses part of the value | UserMenu when the theme changes, if they share a provider |
| 4. A subscribed value from an external store changes | Redux's useSelector, TanStack Query's useQuery, useSyncExternalStore |
FleetSummary when a revalidation arrives |
Notice what's not on the list: "its props changed." Props don't trigger anything on their own. A component receives new props because its parent re-ran (cause 2), and that's the correct causal order.
flowchart TD
A["setState in CataloguePage"] --> B["React re-runs<br/>CataloguePage"]
B --> C["Re-runs its ENTIRE subtree<br/>whether props changed or not"]
C --> D["BikeSearch"]
C --> E["TypeSelector"]
C --> F["BikeList"]
F --> G["BikeCard x N"]
G --> H{"Does the resulting JSX<br/>differ from before?"}
H -->|No| I["React doesn't touch the DOM<br/>cost: only the JS function"]
H -->|Yes| J["React applies the<br/>minimal changes to the DOM"]
- The ecosystem's most expensive misunderstanding
Here's the sentence you need to internalize before writing a single memo:
A component re-rendering doesn't mean the browser repaints anything. It means React calls a JavaScript function again and compares the result.
A render is: run the component's function, get a tree of lightweight objects (React elements), compare it with the previous one, and apply to the DOM only what changed. If BikeCard returns exactly the same JSX as before, the DOM isn't touched. This completely changes the scale of the problem:
| Operation | Rough cost | Worth worrying about? |
|---|---|---|
| Running a simple component's function | 0.01 – 0.1 ms | No |
| Comparing its element tree | Proportional to the number of nodes, very cheap | No |
| Running it 2,000 times (whole catalogue) | 20 – 200 ms | Yes: noticeable |
| Writing to the real DOM (creating, moving, or removing nodes) | 0.5 – 5 ms per node | Yes, this is the expensive part |
| Triggering a layout recalculation | 5 – 50 ms | Yes, very expensive |
| Sorting or filtering 2,000 objects | 1 – 15 ms per render | Depends how often it happens |
Formatting dates with Intl 2,000 times |
50 – 300 ms | Yes: Intl is surprisingly expensive |
The correct reading: an extra render isn't a bug, it's a data point. A hundred renders of trivial components can be irrelevant; a single render that sorts a 2,000-item array and formats 2,000 dates can wreck the interaction. That's why the goal is never "reduce the number of renders," but reduce the work done per interaction. With that distinction clear, the seven techniques that follow explain themselves: all of them eliminate work, they don't speed it up.
- Technique 1: lower state to the component that uses it
It's the most cost-effective technique of all, and it needs no new API. If a piece of state sits higher than where it's needed, every change re-runs a much larger subtree than necessary. The fix is to move it down: it's the inverse operation of "lifting state up" from 04-01, and it answers the same question — who needs this data? — with the opposite answer.
// ❌ BEFORE: the form draft lives in the page
function NewBookingPage() {
const [hours, setHours] = useState(1); // only the form uses it
const { data: bikes } = useBikes();
const { data: stations } = useStations();
return (
<Panel title="New booking">
<FleetSummary bikes={bikes} stations={stations} />
<Breadcrumbs />
<BookingForm hours={hours} onHoursChange={setHours} />
</Panel>
);
}Every keystroke in the hours field re-runs NewBookingPage, and with it FleetSummary — which aggregates all 2,000 bikes by status — and Breadcrumbs. The field types a number and the cost is a full recount of the fleet.
// ✅ AFTER: the draft lives where it's used
function NewBookingPage() {
const { data: bikes } = useBikes();
const { data: stations } = useStations();
return (
<Panel title="New booking">
<FleetSummary bikes={bikes} stations={stations} />
<Breadcrumbs />
<BookingForm /> {/* the hours state lives inside */}
</Panel>
);
}
function BookingForm() {
const [hours, setHours] = useState(1); // the render stops here
// …
}Now typing in the field re-runs a single component. No memo, no useCallback, no dependencies to maintain. And the code is shorter than before, not longer: that's the sign that the optimization is structural, not a patch.
The operating rule, which you already saw in 07-01 when classifying state: place each piece of state at the lowest common ancestor of whoever reads it, not higher.
- Technique 2: pass
children to isolate a subtree
children to isolate a subtreeWhen state can't be lowered because the very component wrapping the rest is the one consuming it, there's a second structural technique, which already showed up in 04-02 and here reveals its real usefulness. The trick is in how children works: the JSX passed as children is created in the parent component, not in the one that receives it. If the parent doesn't re-run, those elements are literally the same objects as before, and React skips rendering them.
// ❌ BEFORE: Layout owns the sidebar's state and creates the content
function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className={styles.frame}>
<Header onToggleSidebar={() => setSidebarOpen((v) => !v)} />
{sidebarOpen && <Sidebar />}
<main>
<Outlet /> {/* the whole page re-runs when the sidebar opens */}
</main>
<Footer />
</div>
);
}Opening the sidebar re-runs Layout, and with it <Outlet /> and the entire page underneath: the catalogue with its 2,000 cards. A purely decorative change to the frame costs a render of the whole application.
// ✅ AFTER: the state moves down into a component that receives children
function Layout() {
return (
<FrameWithSidebar>
<main>
<Outlet />
</main>
</FrameWithSidebar>
);
}
function FrameWithSidebar({ children }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className={styles.frame}>
<Header onToggleSidebar={() => setSidebarOpen((v) => !v)} />
{sidebarOpen && <Sidebar />}
{children} {/* created by Layout, which has NOT re-run */}
<Footer />
</div>
);
}Now setSidebarOpen re-runs only FrameWithSidebar. The children prop it receives is the same object as in the previous render, React detects that by identity, and doesn't descend down that branch. The catalogue never finds out.
This technique is sometimes called "structural memoization": it gets
memo's effect withoutmemo, without comparison, and without extra memory — just by putting the state in the right spot in the tree.
- Technique 3: derive during render instead of storing and syncing
The third technique doesn't reduce renders: it reduces work and eliminates an entire class of bugs. It's the same lesson from 05-02 about unnecessary effects, read now through the lens of performance.
// ❌ Redundant state synced with an effect
function FleetSummary({ bikes }) {
const [available, setAvailable] = useState(0);
useEffect(() => {
setAvailable(bikes.filter((b) => b.status === 'disponible').length);
}, [bikes]);
return <p>{available} bikes available</p>;
}This component does two renders per change: one with the old value and another after the effect. On top of that, it shows incorrect data for an instant, and if bikes changes identity without changing content, the effect fires anyway.
// ✅ Derived during render: a single render, always consistent
function FleetSummary({ bikes }) {
const available = bikes.filter((b) => b.status === 'disponible').length;
return <p>{available} bikes available</p>;
}A single render, impossible to desync, and less code. The question that immediately comes up — "what if the calculation is expensive?" — is answered in 08-03 with useMemo, but the order matters: derive first, and only memoize if measuring shows the calculation is heavy. Storing in state what can be computed is a correctness problem before it's a speed one.
- Technique 4: stable keys in lists
03-03 established why key isn't decoration. From a performance angle, the effect of a badly chosen key is brutal and silent.
// ❌ Index as the key
{visibleBikes.map((bike, index) => (
<BikeCard key={index} bike={bike} />
))}
// ❌ Even worse: a new key on every render
{visibleBikes.map((bike) => (
<BikeCard key={crypto.randomUUID()} bike={bike} />
))}
// ✅ Real, stable identity from the data
{visibleBikes.map((bike) => (
<BikeCard key={bike.id} bike={bike} />
))}| Key | What happens when sorting by price | Cost |
|---|---|---|
key={index} |
React thinks every element's content changed; it updates every component's props and keeps state at the wrong position | Lots of DOM writes + state bugs |
key={crypto.randomUUID()} |
React destroys and recreates every node on every render | Catastrophic: full DOM + remounted effects |
key={bike.id} |
React moves the existing nodes | Minimal, and state travels with its element |
With 2,000 cards and catalogueSlice's sort switching from precio to modelo, the difference between the first and the third option is the difference between a smooth animation and a half-second freeze. And it costs nothing: it's just writing the right key.
- Technique 5: delay and throttle text input
useDebounce has been in the project since 05-06, and BikeSearch uses it. It's worth revisiting now for what it actually is: a performance technique that reduces how often work happens rather than reducing its cost.
// src/components/BikeSearch.jsx (reminder)
function BikeSearch() {
const [text, setText] = useState('');
const delayedTerm = useDebounce(text, 300);
const dispatch = useDispatch();
useEffect(() => {
dispatch(searchTermChanged(delayedTerm));
}, [delayedTerm, dispatch]);
return (
<input
type="search"
value={text} // the field responds to every keystroke
onChange={(e) => setText(e.target.value)}
aria-label="Search bikes by model"
/>
);
}The key to the pattern: the <input> keeps updating on every keystroke (it's a cheap component and its response needs to be instant), but filtering the 2,000 bikes happens exactly once, 300 ms after the last keystroke. Typing "electrica" goes from 12 filter passes down to 1.
Its close relative is throttling, which instead of waiting for silence guarantees at most one execution every X milliseconds. It's the right choice for continuous events that have no "end": scrolling, resizing, mouse movement.
// src/hooks/useThrottle.js
import { useState, useEffect, useRef } from 'react';
export function useThrottle(value, intervalMs = 200) {
const [throttledValue, setThrottledValue] = useState(value);
const lastRun = useRef(Date.now());
useEffect(() => {
const remaining = intervalMs - (Date.now() - lastRun.current);
if (remaining <= 0) {
lastRun.current = Date.now();
setThrottledValue(value);
return;
}
const id = setTimeout(() => {
lastRun.current = Date.now();
setThrottledValue(value);
}, remaining);
return () => clearTimeout(id);
}, [value, intervalMs]);
return throttledValue;
}How to choose between the two:
useDebounce |
useThrottle |
|
|---|---|---|
| When it fires | When the user stops | At regular intervals while it's happening |
| Guarantees intermediate runs | No | Yes |
| Typical case | Search, autosave, remote validation | Scrolling, resize, dragging |
| In CicloUrbano | BikeSearch |
useWindowWidth on resize |
- Technique 6: paginate and virtualize long lists
Suppose CicloUrbano grows and db.json ends up with 2,000 bikes spread across the city — this is the scenario the module will use from here on. Painting 2,000 BikeCards means creating on the order of 20,000 DOM nodes. No amount of memoization fixes that, because the work is real: the browser has to lay out and paint everything that exists.
There are two answers, and the first is almost always the better one:
Paginate. json-server already supports _page and _limit, and 07-06 set up the paginated query with placeholderData so the list doesn't flicker. If the user sees 24 bikes per page, the problem disappears at the root: there are never more than 24 cards in the DOM, and less data gets downloaded too.
Virtualize (or "sliding window"). When the design calls for a continuous, infinitely scrolling list, the technique is to mount only the visible elements plus a small margin, and simulate the total height with an empty container so the scrollbar stays believable.
flowchart LR
subgraph WITHOUT["Without virtualization"]
A["2,000 cards in the DOM<br/>~20,000 nodes"]
end
subgraph WITH["Virtualized"]
B["Container with total height<br/>2,000 x 120px = 240,000px"]
B --> C["Only ~14 cards mounted<br/>the visible ones + margin"]
C --> D["On scroll: reused,<br/>changing data and position"]
end
Today's standard library is @tanstack/react-virtual, from the same authors as TanStack Query, with no dependencies and framework-agnostic.
// src/components/VirtualBikeList.jsx
import { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import BikeCard from './BikeCard.jsx';
import styles from './VirtualBikeList.module.css';
function VirtualBikeList({ bikes }) {
const containerRef = useRef(null);
const virtualizer = useVirtualizer({
count: bikes.length, // 1) how many items there are in total
getScrollElement: () => containerRef.current, // 2) who owns the scroll
estimateSize: () => 120, // 3) estimated height of each card, in px
overscan: 5 // 4) how many to mount outside the view
});
return (
<div ref={containerRef} className={styles.window}>
{/* 5) A div with the TOTAL height: makes the scrollbar believable */}
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((item) => {
const bike = bikes[item.index];
return (
// 6) Each card is positioned absolutely at its real offset
<div
key={bike.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${item.size}px`,
transform: `translateY(${item.start}px)`
}}
>
<BikeCard bike={bike} />
</div>
);
})}
</div>
</div>
);
}
export default VirtualBikeList;Point by point:
countis the logical number of elements; the virtualizer never iterates over all of them.getScrollElementreturns the element withoverflow: autothat produces the scroll.estimateSizecan be approximate: if heights vary, it's corrected by measuring withmeasureElement.overscanmounts a few elements outside the view so a fast scroll doesn't show gaps.- The inner container has the real total height (2,000 × 120 px = 240,000 px) even though it's nearly empty.
transform: translateY(...)is preferable totopbecause it doesn't trigger a layout recalculation.
The result in numbers: from ~20,000 DOM nodes to ~140. No other technique in this module comes close to that improvement. Before memoizing a long list, ask yourself whether it should be short.
Honest warnings about virtualization, because it isn't free: it breaks the browser's find function (Ctrl+F can't find what isn't mounted), it complicates accessibility and keyboard focus, it demands care with variable heights, and it interferes with printing. That's why pagination is usually the better product decision.
- Technique 7: take work off the main thread
The browser has a single thread to run JavaScript, respond to events, and paint. Anything that occupies that thread for more than ~50 ms turns into an unresponsive interface. The levers, from simplest to most complex:
| Lever | What it does | When to use it in CicloUrbano |
|---|---|---|
| Do less | Paginate, filter server-side, request fewer fields | json-server with _page, _limit, and _sort instead of fetching 2,000 bikes |
| Do it once | Precompute and store in Query's cache | The per-station count, computed in the query's select |
| Do it later | useTransition / useDeferredValue (08-03) |
Filtering the catalogue while the field keeps responding |
| Do it elsewhere | Web Worker | A monthly usage report over thousands of bookings |
| Let CSS do it | Animate with transform and opacity, which don't touch layout |
The Modal's opening transition |
| Let the browser do it | content-visibility: auto, loading="lazy" on images |
Station photos below the fold |
An example of the last case, often overlooked because it isn't "React code":
/* src/components/BikeList.module.css */
.card {
content-visibility: auto; /* the browser doesn't lay out what isn't visible */
contain-intrinsic-size: 0 120px; /* reserved height so the scroll doesn't jump */
}Two lines of CSS that stop the browser from laying out cards that are off-screen. It isn't virtualization — the nodes still exist in the DOM — but the layout and paint cost drops a lot, and it needs no library at all.
- React 19's React Compiler: what it automates and what it doesn't
React 19 stabilized the React Compiler, and it's worth explaining precisely because it produces two opposite misunderstandings: the person who thinks memoization no longer needs learning, and the person who ignores it entirely.
What it is. A compiler that runs at build time (like a Babel plugin inside Vite), analyzes your components and hooks, and automatically inserts the memoization you would have hand-written with memo, useMemo, and useCallback. It turns your code into an equivalent version that reuses values, functions, and JSX elements when their inputs haven't changed.
How to enable it in Vite:
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: [['babel-plugin-react-compiler', { target: '19' }]]
}
})
]
});And the golden rule for adopting it sensibly: before turning it on, run the React rules linter (eslint-plugin-react-hooks includes the compiler's rules). The compiler is conservative: if it detects a component breaking React's rules — mutating props, writing to a module-level variable during render, reading ref.current while rendering — it skips that component and leaves it unoptimized, silently. A project with lots of violations gets far fewer improvements than it expects.
What it automates and what it doesn't:
| Work | Does the compiler do it? |
|---|---|
| Memoizing a calculation's result across renders | Yes, the equivalent of useMemo |
| Stabilizing the identity of functions defined in the component | Yes, the equivalent of useCallback |
| Avoiding re-running a component whose props didn't change | Yes, the equivalent of memo |
Stabilizing a context provider's value |
Yes, if the value is built inside the component itself |
| Reducing the JavaScript bundle's size | No. That's 08-04's job |
| Avoiding a calculation that's expensive by itself on its first run | No. Sorting 2,000 bikes still costs what it costs |
| Knowing that an external dependency (an imported function, an object from a library) is stable | No. It can't reason beyond your component |
Fixing a useEffect that fires too often |
No, and it's still your responsibility |
| Optimizing code that breaks React's rules | No: it skips it entirely |
Deciding to lower state, pass children, paginate, or virtualize |
No. None of this lesson's structural decisions |
Why this module is still essential, even if you turn the compiler on tomorrow:
- To read existing code. The vast majority of React projects in production are full of hand-written
memo,useMemo, anduseCallback. You can't maintain what you don't understand. - To debug. When something goes wrong with the compiler enabled, diagnosing it requires knowing exactly what memoization was expected and which one didn't show up.
- Because many projects won't adopt it. Large codebases, older React versions, build chains that don't use Babel.
- Because it doesn't remove judgment. The compiler memoizes; it doesn't decide what's worth calculating, what should be paginated, or where state should live. That's still the hard work.
- Because memoizing isn't optimizing. If the problem is that you're downloading 1.2 MB of JavaScript or requesting 2,000 records, the compiler doesn't help at all.
The React Compiler removes the mechanical work of memoizing. It doesn't remove the need to understand what gets memoized, why, and when that isn't the solution.
- Performance budget and workflow
A performance budget turns "this feels slow" into a checkable condition. Without one, there's no way to know when to optimize or, above all, when to stop. This is a reasonable budget for CicloUrbano:
| Metric | Budget | How it's measured |
|---|---|---|
| Initial JavaScript (compressed) | < 200 KB | Output of npm run build (08-04) |
| LCP on a mid-range phone | < 2.5 s | Lighthouse in mobile mode |
| INP while typing in the search box | < 200 ms | Profiler + Performance tab (08-05) |
| Commit duration when filtering the catalogue | < 16 ms | React DevTools Profiler (08-05) |
| Cards mounted at once | < 60 | Elements inspector |
And this is the workflow that structures the whole module. Read it as a loop, not a list:
flowchart TD
A["1. Define the budget<br/>and the specific interaction"] --> B["2. MEASURE in a production build<br/>Profiler + Lighthouse"]
B --> C{"Does it break<br/>the budget?"}
C -->|No| Z["STOP<br/>nothing to optimize"]
C -->|Yes| D["3. Locate the bottleneck<br/>which component, which commit, why"]
D --> E{"What kind of<br/>problem is it?"}
E -->|"Initial download"| F["Code splitting 08-04"]
E -->|"Too many elements"| G["Paginate / virtualize"]
E -->|"Misplaced state"| H["Lower state / children"]
E -->|"Excessive frequency"| I["Debounce / transitions"]
E -->|"Renders with equal props"| J["memo 08-02"]
E -->|"Expensive calc or identity"| K["useMemo / useCallback 08-03"]
F --> L["4. Apply the SIMPLEST technique<br/>one at a time"]
G --> L
H --> L
I --> L
J --> L
K --> L
L --> M["5. MEASURE AGAIN"]
M --> N{"Improved<br/>noticeably?"}
N -->|Yes| B
N -->|No| O["Revert the change<br/>and go back to step 3"]
O --> D
Two details in the diagram that aren't decorative:
- "Apply a single technique at a time." If you apply
memo,useCallback, and virtualization all at once and things improve, you don't know which one did it; you're probably carrying two useless optimizations forever. - "Revert the change." An optimization that doesn't measurably improve anything must be undone. Its cost in readability and memory is still there even when the benefit isn't.
- Map of the rest of the module
| Lesson | Tool | Problem it solves |
|---|---|---|
| 08-02 | React.memo |
A component re-runs even though its props are identical |
| 08-03 | useMemo, useCallback, useTransition, useDeferredValue |
A calculation is expensive, or a value changes identity and ruins everything downstream |
| 08-04 | React.lazy, Suspense, import() |
The browser downloads code it doesn't need yet |
| 08-05 | React DevTools Profiler, <Profiler> |
Knowing what's actually happening, which is step 2 of everything above |
Common Mistakes and Tips
Optimizing in development mode. Vite's dev server, React's warnings, and StrictMode's double render distort any measurement. Anything measured for real gets measured with npm run build and npm run preview (08-05).
Chasing the render count as a goal. "I went from 340 renders to 12" isn't an achievement if the interaction took 30 ms and still takes 28. The metric is time, not the count.
Starting with memoization. It's the order reversed. By the time you reach memo having already ruled out lowering state, children, deriving, paginating, and virtualizing, the memo is usually unnecessary. When you start with memo, you almost always end up with a project full of memoization and the real problem untouched.
Tip: measure on a slow device. Browser devtools let you throttle the CPU 4× or 6×. A problem that's invisible on your laptop becomes obvious at 4×, which is roughly a mid-range phone.
Tip: put the problem in its layer. Before touching React, check image sizes, the number of requests, whether the server paginates, and whether fonts block painting. Many "slow React apps" are apps with 3 MB of images.
Tip: write the budget into the repo. A PERFORMANCE.md file with the five figures from section 14 turns a discussion of opinions into an objective check, and it survives team changes.
Tip: turn on the linter's rules before the compiler. eslint-plugin-react-hooks with the React Compiler's rules tells you which components break React's rules. Fixing them improves the code even if you never turn on the compiler, and it's a prerequisite for the compiler to be worth anything.
Exercises
Exercise 1. The following CicloUrbano component causes typing in the notes field to repaint the whole bookings panel, including BookingsPanel, which aggregates the user's full history. Identify the problem, say which technique from this lesson fixes it, and rewrite the component.
function BookingsPage() {
const [note, setNote] = useState('');
const { data: bookings } = useBookings();
return (
<Panel title="My bookings">
<BookingsPanel bookings={bookings} />
<FleetSummary />
<label>
Internal note
<input value={note} onChange={(e) => setNote(e.target.value)} />
</label>
<button onClick={() => saveNote(note)}>Save note</button>
</Panel>
);
}Exercise 2. For each CicloUrbano symptom, say which metric from section 3 gets worse and which technique from this lesson (or which lesson in the module) is the right one. Don't use memo, useMemo, or useCallback in any answer.
| # | Symptom |
|---|---|
| a | The first visit takes 4.1 s to show the catalogue on 4G |
| b | Switching the catalogue's sort from price to model freezes the screen for 600 ms |
| c | Every keystroke in the search box fires a request to json-server |
| d | Once the station photos finish loading, the list jumps and the user taps the wrong thing |
| e | Scrolling through the 2,000 bikes is choppy on mobile |
Exercise 3. A coworker proposes turning on the React Compiler and deleting every memo, useMemo, and useCallback in the project "because now they're automatic." List at least four concrete technical objections and describe what check you'd run before accepting or rejecting the proposal.
Solutions
Solution 1. The problem is misplaced state: note is only used by the <input> and the button, but it lives in BookingsPage, so every keystroke re-runs BookingsPanel and FleetSummary. The technique is technique 1, lowering state (section 6), by extracting the field and its button into their own component.
function BookingsPage() {
const { data: bookings } = useBookings();
return (
<Panel title="My bookings">
<BookingsPanel bookings={bookings} />
<FleetSummary />
<InternalNoteField /> {/* the state stops here */}
</Panel>
);
}
function InternalNoteField() {
const [note, setNote] = useState('');
return (
<>
<label>
Internal note
<input value={note} onChange={(e) => setNote(e.target.value)} />
</label>
<button onClick={() => saveNote(note)}>Save note</button>
</>
);
}Now typing re-runs only InternalNoteField. There's no need for memo on BookingsPanel or on FleetSummary: React doesn't even descend down that branch. It's the module's structural lesson: the best optimization is the one that makes optimization unnecessary.
Solution 2.
| # | Metric | Technique |
|---|---|---|
| a | LCP / FCP (and TTI) | Code splitting and lazy loading (08-04), plus paginating the initial query |
| b | INP / TBT | Check stable keys first (technique 4): with key={index} a re-sort rewrites the entire DOM. Then paginate or virtualize (technique 6), and if needed, useTransition (08-03) |
| c | INP and server load | useDebounce (technique 5), already present in BikeSearch; combined with the query's staleTime |
| d | CLS | Reserve the space: width/height or aspect-ratio on images, and skeletons with the final height instead of a "Loading…" text (revisited in 08-04) |
| e | INP / TBT and memory | Paginate or virtualize (technique 6) with @tanstack/react-virtual, and content-visibility: auto as an immediate measure (technique 7) |
None of the five gets solved by memoizing. That's exactly the point of the exercise.
Solution 3. Objections:
- The compiler doesn't memoize what it can't analyze. If a component breaks React's rules, it skips it silently; deleting the manual memoization leaves those components worse off than before, with no warning at all.
- It doesn't cover external dependencies. A function imported from a utility, or an options object created outside the component, still need human judgment; the compiler reasons inside the component only.
- It doesn't make expensive calculations cheap. Sorting and filtering 2,000 bikes costs the same the first time; the compiler avoids repeating it, not doing it.
- It breaks the code for whoever hasn't turned it on. If part of the project gets extracted into a shared library, or another team builds without the plugin, the memoization disappears.
- It removes implicit documentation. A
useMemowith explicit dependencies communicates a design intent that the code without it no longer expresses. - It's effectively an irreversible change. Reintroducing manual memoization across hundreds of components costs far more than leaving it be.
Check to run first: measure before and after with the Profiler (08-05) on the critical interactions — typing in the search box, changing the sort, opening the BookingDialog — on a production build, with and without the compiler, and with the React rules linter green. If the numbers come out equivalent, the compiler is doing its job and removing the manual memoization can be considered gradually, component by component, not in one single operation.
Conclusion
This module starts with method because without method, optimization is superstition. The rule governing everything is measure first: if you can't point to a number breaking a budget, you don't have a problem, you have a suspicion, and acting on suspicions costs readability, memory, and — worst of all — dependency bugs that make the app show incorrect data.
"Slow" has been turned into concrete quantities: FCP and LCP depend mostly on bundle size and the server; INP and TBT are React's own territory and this module's; CLS gets wrecked by badly designed loading indicators. And the causal model has been fixed in place: a component re-runs for four reasons — its state, an ancestor, a context it consumes, a subscribed external store — and "its props changed" isn't among them. Hence the ecosystem's most expensive misunderstanding, now defused: re-rendering isn't repainting. A render is running a function and comparing the result; the DOM only gets touched where something changed. What's expensive isn't the renders, it's the work per interaction.
With that settled, the seven techniques that almost always pay off more than memoization: lowering state to the component that uses it (the most cost-effective of all, and it shortens the code too), passing children so a subtree keeps its identity and React doesn't descend down that branch, deriving during render instead of storing and syncing with an effect, stable keys in lists — where a key={index} turns a re-sort into a full DOM rewrite —, useDebounce and useThrottle to reduce how often work happens, paginating or virtualizing with @tanstack/react-virtual once the catalogue grows to 2,000 bikes, and taking work off the main thread with precomputation, CSS, and content-visibility. None of them needs memo.
React 19's React Compiler has been presented without exaggeration: it automates the mechanical memoization you would have hand-written, it doesn't shrink the bundle, it doesn't make an expensive calculation cheaper, it doesn't reason about external dependencies, it silently skips code that breaks React's rules, and it makes no structural decisions. You still need to understand manual memoization to read existing code, to debug, and for the projects that won't adopt it. And the workflow that structures the module: measure → locate → apply the simplest technique, one at a time → measure again → revert if it doesn't improve.
Now it's finally the tools' turn. The first one answers cause 2 from the list — "an ancestor has re-rendered" — for when there's no structural room left to avoid it: wrapping a component so React compares its props and skips running it if they're the same. The next lesson is Memoization with React.memo.
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
