This whole module has leaned on a rule that, until now, has only ever been stated without being fully backed up: don't optimize what you haven't measured. There's been talk of renders that shouldn't happen, of props changing identity, of 40 ms computations and 271 KB bundles, and every one of those claims was really a promise: "this can be checked." This lesson is where that promise gets cashed in. The React DevTools Profiler records what happens inside React during an interaction and answers, with precision, the three questions that turn a suspicion into a diagnosis: which components rendered, how long each one took, and — the most valuable one — why. With that last answer, "I think the list is over-rendering" stops being a hunch and becomes "BikeCard rendered 2,000 times in a 214 ms commit because the onBook prop changed." The difference between those two sentences is the difference between touching code blindly and fixing an actual problem. By the end of the lesson you'll walk through CicloUrbano's complete case — measure, diagnose, fix, measure again — and you'll also know the hardest part of all: when to stop.
Contents
- Installing React DevTools and what each tab gives you
- Why you measure in a production build
- Anatomy of the Profiler: recording an interaction
- Commits, the flame graph, and the ranked chart
- The numbers: render duration, self time, and render count
- Why this repainted: the four causes
- Highlighting updates in the browser
- Case study: CicloUrbano's search box, before and after
- The
<Profiler>API in code - Complements outside React
- When to stop optimizing
- Installing React DevTools and what each tab gives you
React DevTools is an official extension available for Chrome, Edge, and Firefox, and also as a standalone app (npx react-devtools) for debugging React Native or pages served outside a desktop browser.
Once installed, opening the browser's dev tools on a page running React shows two new tabs:
| Tab | What it's for | When you use it |
|---|---|---|
| ⚛️ Components | Component tree with real names, props, state, hooks, and contexts. Lets you edit them live | Understanding the structure, inspecting a value, checking which context reaches a component |
| ⚛️ Profiler | Recording renders with timings and causes | Measuring performance: this lesson's content |
The React icon in the browser toolbar also gives you instant information without opening anything: if it's in color, the page uses React; and its shade tells you whether it's in development mode (red) or production (blue). That detail matters more than it looks, and the next section explains why.
Two settings in the Components tab worth knowing before profiling:
Highlight updates when components render: draws a box around every component that renders. It's the fastest visual diagnostic there is (section 7).- The ✨ Memo ✨ badge next to a component's name: it means the React Compiler has optimized it (08-03). If your critical component doesn't have it in a compiled project, the compiler skipped it.
- Why you measure in a production build
This is the module's most-ignored instruction, and the one that invalidates the most measurements.
npm run build # bundles with Vite in production mode
npm run preview # serves dist/ at http://localhost:4173What distorts development mode, point by point:
| Development-mode factor | Effect on measurements |
|---|---|
StrictMode runs every component twice |
Render times double. A 4 ms component shows up as 8 |
| React's warnings and checks | React validates props, keys, and hook rules on every render: a cost that doesn't exist in production |
| Unminified code | More code to parse, and functions the JavaScript engine hasn't optimized |
| Unbundled modules | Vite serves each file separately: hundreds of requests that are one in production |
| Source maps | Extra memory and work for the browser |
| Hot reload (HMR) | Permanent instrumentation across the component tree |
| The Profiler's own instrumentation | Adds measurable cost, even in production |
The practical consequences:
- Absolute times from development mode are useless. A 214 ms commit in development might be 60 ms in production.
- Proportions do hold up. If
BikeCardaccounts for 80% of the commit in development, it'll still be the problem in production. That's why developing with the Profiler open is useful for locating issues, even though the final figures have to come from a build. - Conclusions of the "it repainted N times" kind are valid in development, once you subtract
StrictMode's effect.
One important technical detail: React's standard production build strips out the Profiler's instrumentation. To profile in production you have to build with profiling enabled:
// vite.config.js — only for measurement builds, not for deploying
export default defineConfig({
resolve: {
alias: {
'react-dom/client': 'react-dom/profiling'
}
},
build: {
minify: 'terser',
terserOptions: { keep_fnames: true, keep_classnames: true } // readable names
}
});keep_fnames deserves a comment: without it, minification renames functions and the Profiler shows t, e, n instead of BikeCard. With it, the bundle weighs a bit more, but the flame graph is readable. It's a measurement configuration, not a deployment one.
Recommended flow: locate in development (fast, with hot reload) and confirm and quantify in a profiling-enabled production build. Never close out an issue with development figures.
- Anatomy of the Profiler: recording an interaction
The recording cycle has five steps, and the second one is the one almost everybody skips:
- Open the Profiler tab.
- Prepare the initial state: navigate to the screen, wait for the data to load, let the interface settle. If you record the initial load together with the interaction, you won't be able to tell them apart.
- Click the record button (blue circle).
- Do one single thing: type five letters into the search box. Nothing else.
- Stop the recording and analyze it.
Before recording, turn on these two settings in the Profiler's gear icon ⚙:
| Setting | What it does | Recommendation |
|---|---|---|
| Record why each component rendered | Records the cause of each render | ✅ Always on: it's half the tool's value |
| Hide commits below _ ms | Hides trivial commits | Useful at 1–2 ms so you don't get lost in the noise |
Highlight updates when components render |
Visual boxes on the page | Turn on for quick diagnosis, turn off when measuring times |
A principle that saves a lot of time: one recording, one interaction. Recording "I open the app, navigate, filter, type, and book" produces a log that's impossible to interpret. Keep recordings short, with a specific hypothesis.
- Commits, the flame graph, and the ranked chart
The commit timeline
Across the top you'll see a series of bars: each one is a commit — one instance of React applying changes to the DOM. Remember the cycle from 01-05: render → diff → commit. The Profiler measures from when React starts rendering to when it finishes applying the changes.
- The height of each bar is that commit's duration.
- The color runs from gray (fast) to yellow (slow). Yellow grabs your attention, but the real criterion is the milliseconds.
- Clicking a bar lets you examine that specific commit.
Five keystrokes should produce roughly five commits. If you see fifteen, you already have a data point: something is triggering cascading renders.
Flame graph (flamegraph)
It's the default view, and it shows that commit's component tree.
- Width = time that component and its descendants took to render.
- Vertical position = depth in the tree: children below parents.
- Color: gray = didn't render in this commit; yellow to green = it rendered, with yellow indicating more time.
What to look for: wide, yellow bars, and above all colored bars where you expected gray. A component that shouldn't have rendered showing up in color is exactly the kind of finding you're after.
Ranked chart (ranked)
The same information sorted from highest to lowest time, without the tree structure. It's the view for answering "what's costing me time?" in two seconds. The flame graph answers "why is it happening?"
flowchart TD
A["Record the interaction"] --> B["Commit timeline:<br/>how many commits, and how long"]
B --> C{"Any commits<br/>above 16 ms?"}
C -->|No| Z["No measurable problem here"]
C -->|Yes| D["Ranked view:<br/>WHICH component consumes the time"]
D --> E["Flamegraph view:<br/>WHERE it is in the tree"]
E --> F["Side panel:<br/>WHY it rendered"]
F --> G["Full diagnosis:<br/>component + cost + cause"]
- The numbers: render duration, self time, and render count
Selecting a component in either view shows its numbers in the side panel. These are the ones worth knowing how to read:
| Metric | What it exactly measures | How to interpret it |
|---|---|---|
| Render duration | The component's time and its entire subtree's | High on an ancestor can be entirely its children's fault: don't blame the parent without looking below |
| Self time | Time for that component alone, with no descendants | The metric that points at the real culprit |
| Renders per commit | How many instances of that component rendered | "2,000" on a list is the classic tell |
| Total commit duration | All of React's work in that update | The budget is 16 ms (one frame at 60 fps) |
| Ranked position | Rank within that commit | Attack number 1; number 12 rarely matters |
The distinction between the first two is the one that prevents the most diagnostic mistakes:
CataloguePage render duration: 214 ms self time: 0.8 ms
BikeList render duration: 212 ms self time: 2.1 ms
BikeCard (x2000) self time: 0.1 ms each → 209 msCataloguePage looks like the culprit at 214 ms, but its self time is 0.8 ms: it's barely doing anything. The cost is spread across 2,000 cards at 0.1 ms each. The problem isn't that one card is expensive; it's that there are 2,000 of them. That distinction decides the fix: optimizing the inside of BikeCard won't help here — what helps is preventing them from running (memo) or from existing at all (paginate, virtualize).
And the opposite case, with the same total:
Here it's real: a single component consumes 178 ms on its own. There's a heavy computation inside, and the tool for that is useMemo or a better algorithm.
- Why this repainted: the four causes
With "Record why each component rendered" turned on, the side panel adds a "Why did this render?" section, and that's where the tool's differential value lives. The causes it can report are exactly 08-01's four:
| Profiler message | Cause | What to do |
|---|---|---|
| "Props changed: (onBook, onSelect)" | The identity of those props changed | Stabilize them with useCallback/useMemo (08-03), or pass primitives |
| "Hooks changed" / "State changed" | Its own state changed | Correct by definition. If it's not needed, the state is in the wrong place: push it down or lift it up (08-01) |
| "Context changed" | A context it consumes changed | Split the context and stabilize its value (07-02 + 08-03). memo doesn't help here |
| "The parent component rendered" | The parent rendered and this one isn't memoized | memo (08-02), or isolate with children (08-01) |
The first one is the most valuable of all, because it names the culprit prop. Without the Profiler, figuring out which of seven props changed identity means instrumenting the component by hand (as in 08-02). With it, it's one line of text.
And the strategic way to read the table, the one that turns the entire module into a procedure:
flowchart TD
A["Why did this render?"] --> B{"What does it say?"}
B -->|"Parent rendered"| C{"Is the component<br/>expensive or repeated?"}
C -->|Yes| D["memo 08-02"]
C -->|No| E["Leave it: not the problem"]
B -->|"Props changed"| F["Which prop?<br/>Stabilize it with useCallback/useMemo 08-03<br/>or pass primitives"]
B -->|"State changed"| G{"Should it own<br/>that state?"}
G -->|No| H["Push the state down 08-01"]
G -->|Yes| I["Correct: don't touch anything"]
B -->|"Context changed"| J["Split the context 07-02<br/>+ stabilize value 08-03"]
- Highlighting updates in the browser
Before recording anything, there's a five-second diagnostic. In the Components tab, gear icon ⚙ → General → Highlight updates when components render.
From that point on, every component that renders gets surrounded by a colored box for an instant:
| Box color | Render frequency |
|---|---|
| Light blue | Low |
| Green | Medium |
| Yellow | High |
| Red | Very high: look here |
Type a letter into BikeSearch and watch. If the whole screen lights up — header, footer, fleet summary, all 2,000 cards — you've located the problem without recording anything. If only the text field flickers, there's nothing to investigate there.
It's the ideal tool for three things: a quick check before a serious profiling session, confirming at a glance whether a freshly added memo actually worked, and catching continuous renders caused by a badly written effect (a component that flickers without anyone touching it is a render loop).
Turn it off when measuring times: drawing the boxes costs work and pollutes the numbers.
- Case study: CicloUrbano's search box, before and after
This is the complete walkthrough, with the 2,000-bike catalogue, a profiling-enabled production build, and the CPU throttled 4× to simulate a mid-range phone.
Step 1: define the hypothesis and the interaction
Reported symptom: "when typing in the search box, letters take a while to appear."
Interaction to measure: typing "electr" (6 keystrokes) into BikeSearch.
Budget (08-01): INP < 200 ms; commits < 16 ms.
Step 2: measure the initial state
Recording, with the commit timeline:
| Commit | Duration | Components rendered |
|---|---|---|
| 1 | 218 ms | 2,014 |
| 2 | 226 ms | 2,014 |
| 3 | 214 ms | 2,014 |
| 4 | 231 ms | 2,014 |
| 5 | 219 ms | 2,014 |
| 6 | 224 ms | 2,014 |
Six commits at ~220 ms each. The 16 ms budget is blown fourteen times over. The feeling that "the letters are slow" is confirmed with numbers: every keystroke blocks the main thread for more than a fifth of a second.
Ranked view for commit 3:
1. BikeCard (x2000) 209.4 ms (self total) 2. BikeList 2.1 ms 3. CataloguePage 0.8 ms 4. FleetSummary 0.7 ms 5. BikeSearch 0.3 ms
Flamegraph view: 95% of the width is the BikeCard row.
Step 3: locate the cause
Pick any BikeCard and read the side panel:
BikeCard (bici-1487)
Render duration: 0.11 ms
Self time: 0.11 ms
Why did this render?
→ Props changed: (onSelect, onBook)Complete diagnosis, and notice there's no guesswork left in it:
BikeCardisn't memoized (08-02), so it reruns on every render of its parent.- Even if it were,
onSelectandonBookare arrow functions created inline inBikeList:memowould fail regardless (08-03). - Each card costs little (0.11 ms), but there are 2,000 of them.
- On top of that, all the filtering happens synchronously and urgently, in the same update as the keystroke.
Step 4: apply the fixes
They're applied, and this matters, one at a time, measuring between each step:
4.1 — memo on BikeCard (08-02), also pulling the Intl.NumberFormat out of the component.
const EURO_FORMATTER = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' });
function BikeCard({ bike, stationName, onSelect, onBook }) { /* … */ }
export default memo(BikeCard);Measurement: no change (≈220 ms). And that's a result, not a failure: it confirms point 2 of the diagnosis. A memo with unstable props saves nothing.
4.2 — useCallback in BikeList (08-03).
const handleSelect = useCallback((id) => navigate(`/bicicletas/${id}`), [navigate]);
const handleBook = useCallback((id) => navigate(`/reservas/nueva?bicicleta=${id}`), [navigate]);Measurement: commits drop to ~34 ms. And in the side panel for any card, the message has changed to what we were after:
Only the cards entering or leaving the filter render at all. 84% of the cost is gone with two hooks, but only because the memo from the previous step was already in place: neither one on its own would have helped.
4.3 — useDeferredValue in CataloguePage (08-03), so filtering stops competing with typing.
const deferredSearchTerm = useDeferredValue(searchTerm);
const visible = useMemo(
() => filterAndSort(bikes, stations, deferredSearchTerm, type, sort),
[bikes, stations, deferredSearchTerm, type, sort]
);Measurement: now there are two kinds of commit per keystroke — an urgent one at ~2 ms that only updates the field, and a deferred, interruptible one at ~30 ms for the list — and during fast typing, several of the deferred ones get abandoned before they finish. The field responds instantly.
Step 5: measure again and compare
| Metric | Before | After | Improvement |
|---|---|---|---|
| Average commit duration | 220 ms | 30 ms (deferred) + 2 ms (urgent) | −86% |
| Components per commit | 2,014 | 14 | −99% |
| Commits above 16 ms | 6 of 6 | 0 urgent, 2 deferred | ✅ |
| Measured INP | ~240 ms | ~35 ms | −85% |
| Budget (< 200 ms INP) | ❌ | ✅ | Met |
Step 6: decide whether to stop
The budget is met with room to spare. There's still ~30 ms left in the deferred commits, and it could be attacked by virtualizing the list (08-01). But: they're deferred, they're interruptible, they don't block typing, and the user no longer perceives any delay. Virtualizing would add a dependency, complicate accessibility, and break Ctrl+F, in exchange for an improvement nobody would notice.
Decision: stop here. Document the numbers in PERFORMANCE.md and move on. That decision is as much a part of the craft as the three fixes before it.
- The
<Profiler> API in code
<Profiler> API in codeThe extension measures while you're watching. To measure automatically — in performance tests, in continuous integration, or in production with real users — React offers a component:
import { Profiler } from 'react';
<Profiler id="catalogue" onRender={reportMetric}>
<CataloguePage />
</Profiler>The onRender function receives six arguments:
function reportMetric(
id, // 1) the <Profiler>'s id: "catalogue"
phase, // 2) "mount" | "update" | "nested-update"
actualDuration, // 3) ms for this render (with memoization applied)
baseDuration, // 4) estimated ms WITHOUT any memoization
startTime, // 5) timestamp when React started
commitTime // 6) timestamp when React committed
) {
// …
}The key pair is the third and fourth. baseDuration is what it would cost to render the whole subtree with no memo or useMemo at all; actualDuration is what it actually cost. The difference is, literally, what your memoization is saving:
// src/utils/metrics.js
const THRESHOLD_MS = 16;
export function reportMetric(id, phase, actualDuration, baseDuration) {
const savings = baseDuration - actualDuration;
if (actualDuration > THRESHOLD_MS) {
console.warn(
`[performance] ${id} (${phase}): ${actualDuration.toFixed(1)} ms ` +
`(base ${baseDuration.toFixed(1)} ms, savings ${savings.toFixed(1)} ms)`
);
}
// In production: send to a metrics service, without blocking the thread
if (import.meta.env.PROD && navigator.sendBeacon) {
navigator.sendBeacon(
'/metrics/render',
JSON.stringify({ id, phase, actualDuration, baseDuration, ts: Date.now() })
);
}
}// src/routes.jsx (fragment)
import { Profiler } from 'react';
import { reportMetric } from './utils/metrics.js';
{
index: true,
element: (
<Profiler id="catalogue" onRender={reportMetric}>
<CataloguePage />
</Profiler>
)
}Details worth knowing before using it:
| Aspect | Detail |
|---|---|
| Cost | Not free: it adds work on every render of the subtree. Wrap specific zones, not the whole app |
| In a standard production build | onRender doesn't get called: you need the profiling-enabled build (section 2) |
| Nesting | You can nest <Profiler>s with different ids to measure by zone |
One id per measured zone |
The id travels with every call: use it to aggregate metrics by screen |
| Doesn't measure the DOM or the network | Only React's work. For everything else, section 10 |
nested-update |
A render triggered by a setState call inside a cleanup effect: a sign of a pattern worth improving |
A very practical use in automated tests: log actualDuration to a file during an end-to-end test and fail the build if it exceeds a threshold. That's how a performance budget defends itself, without depending on someone remembering to measure. That kind of automated check is, precisely, the territory of Module 9.
- Complements outside React
The Profiler measures React's work, and React's work is only part of the total. These are the tools that cover the rest, mentioned so you know when to switch instruments:
| Tool | What it measures that the Profiler doesn't | When to use it |
|---|---|---|
| Browser's Performance tab | The whole main thread: JavaScript, layout, paint, network, long tasks | When the Profiler says React is fast and the app still feels slow |
| Lighthouse | LCP, CLS, TBT, best practices, accessibility, with an overall score | Periodic audits, and before a major deploy |
| Network tab | Chunk sizes and order, request waterfalls | When validating 08-04's code splitting |
web-vitals (library) |
LCP, INP, and CLS from real users in production | The measurement that actually matters: your users', not your laptop's |
| CPU and network throttling | Simulating slow devices and connections | Every time you measure: 4× CPU and "Slow 3G" |
PerformanceObserver |
Long tasks, user input, custom marks | Custom instrumentation in production |
The rule for choosing: if the Profiler says React consumes little time and the app still feels slow, the problem isn't React's. It'll be in the network, the images, the CSS, the layout, or a third-party library, and the browser's Performance tab is where you'll see it.
About web-vitals, one thing is worth insisting on: your laptop with localhost isn't a representative sample of anything. Measuring in production with real users reveals distributions — the 75th percentile of INP on Android phones, for instance — that no local measurement shows. This lesson doesn't go into it, but it's the natural destination for everything learned here.
- When to stop optimizing
It's the question that closes out the module, and the one taught the least. There are four clear signals to stop:
1. The budget is met. If you set INP < 200 ms and you're at 35 ms, you're done. A budget that doesn't stop the work once it's met was never a budget.
2. The improvement is no longer perceptible. There's a perceptual threshold: below ~100 ms a response feels instantaneous. Going from 40 ms to 25 ms is a 37% improvement on a spreadsheet and zero improvement for the user.
3. The readability cost outweighs the benefit. A component with four useMemos, three useCallbacks, and a custom comparator, to save 3 ms, is a component the next developer — or you, six months from now — will break the moment they touch it. That risk has a real cost.
4. There's a bigger problem somewhere else. If the app takes 3 s for the first paint, polishing a 20 ms commit further is putting effort in the wrong place. Go back to step 1 of 08-01's workflow and pick the biggest bottleneck, not the most entertaining one.
And the signal that you need to backtrack: if an optimization didn't produce a measurable improvement, revert it. Its complexity and memory cost is still there even if the benefit isn't. That discipline is what keeps a project from filling up with decorative memoization over the years.
flowchart TD
A["Applied an optimization"] --> B["Measure again<br/>production build, 4x CPU"]
B --> C{"Measurable<br/>improvement?"}
C -->|No| D["REVERT<br/>cost with no benefit"]
C -->|Yes| E{"Does it meet<br/>the budget?"}
E -->|No| F["Next bottleneck<br/>the BIGGEST one, not the easiest"]
E -->|Yes| G{"Would anyone notice<br/>further improvement?"}
G -->|Yes| F
G -->|No| H["STOP<br/>document the numbers and move on"]
F --> A
D --> F
Common Mistakes and Tips
Measuring in development mode and trusting the numbers. StrictMode doubles renders and warnings add cost. Locate in development; quantify in a profiling-enabled production build.
Recording too much. A recording with five different interactions is unreadable. One recording, one hypothesis.
Profiling without "Record why each component rendered". That's giving up half the tool: without the cause, you have timings but no diagnosis.
Confusing "render duration" with "self time". The first includes descendants. Blaming a parent with a 214 ms duration and 0.8 ms of self time is starting to optimize the wrong spot.
Chasing the number of rendered components. 2,000 renders at 0.01 ms matter less than one at 180 ms. The metric is time.
Forgetting keep_fnames when building for profiling. A flame graph full of t, e, and n is useless.
Leaving <Profiler> wrapping the whole app in production. It adds cost on every render. Wrap specific zones, and only while you need it.
Tip: save your recordings. The Profiler lets you export a profile to JSON and import it later. Keeping the "before" alongside the "after" turns an improvement into proof, and it's excellent material for a code review.
Tip: always throttle the CPU 4×. It's the difference between optimizing for your laptop and optimizing for your users.
Tip: write the numbers into the repo. A PERFORMANCE.md with the interaction measured, the date, the before and the after turns this module's work into something that outlives whoever did it.
Tip: profile even when everything's fine. Periodic measurement catches regressions while they're still small. Finding that a commit went from 12 ms to 40 ms in the last week is much easier than investigating why the app "feels slow" six months later.
Exercises
Exercise 1. Interpret this recording of CicloUrbano's StationDetailPage, taken in a production build when clicking the "Incidents" tab. Say what the problem is, what is not the problem, and which technique from which lesson you'd apply.
Commit 1 — 187 ms — 63 components Ranked: 1. IncidentsTab 171.2 ms (self: 168.9 ms) 2. StationCard 6.4 ms (self: 0.4 ms) 3. Breadcrumbs 3.1 ms (self: 3.1 ms) 4. Header 2.8 ms (self: 0.2 ms) IncidentsTab panel: Why did this render? → The parent component rendered
Exercise 2. After applying memo to StationCard in StationsPage, the new recording shows this. Explain why the memo didn't help, what specific information tells you that, and how you'd fix it.
Commit 1 — 78 ms — 34 components StationCard panel (est-02): Render duration: 2.1 ms Self time: 1.9 ms Why did this render? → Props changed: (freeDocks, onOpen, style)
Exercise 3. Write a <Profiler> wrapper that logs the worst commit of each measured zone (id) to sessionStorage, storing duration, phase, and timestamp, and that warns via the console when a zone exceeds 16 ms two times in a row. Explain where you'd place it in CicloUrbano and why not at the root.
Solutions
Solution 1. The problem: IncidentsTab consumes 168.9 ms of self time. It isn't a problem of how many components there are — only 63 in the whole commit — but of a single component doing heavy work inside its render: it's probably sorting, grouping, or formatting the incidents, quite possibly with a find inside a map, or an Intl instance created per element.
What is NOT the problem:
- It's not
StationCard: 6.4 ms of duration but 0.4 ms of its own. Its cost belongs to its children. - It's not the number of renders: 63 components is a perfectly healthy figure.
- It's not "The parent component rendered", even though that's the cause listed. The parent rendering is normal when switching tabs; wrapping
IncidentsTabinmemowouldn't save anything, because this render had to happen: the user just clicked the tab.
Technique to apply: useMemo over the heavy computation (08-03), measuring beforehand with console.time to confirm it's actually expensive. And before that, review the algorithm: a Map index instead of linear searches, and a reused Intl.Collator, usually cut the cost more than memoization does. If the first render is still heavy after that, the next option is to keep the computation off the client entirely (useQuery's select, or the server directly).
Solution 2. Why it didn't help: the line Props changed: (freeDocks, onOpen, style) says it literally. Three props change identity on every render of the parent, so memo's shallow comparison always fails and the component runs anyway, with the comparison itself as added cost.
What information tells you that: the "Why did this render?" section with the list of props. Without it you'd have to instrument the component by hand with a tracing comparator (08-02).
How to fix it, prop by prop:
onOpen: an inline arrow function. Stabilize it withuseCallbackin the parent, with[navigate]or[dispatch]as the dependency (08-03).style: an object literal, almost certainly astyle={{ … }}. The best fix isn't to memoize it, but to eliminate it: move it to a CSS Modules class, the project's convention.freeDocks: if it's a number, the by-value comparison should succeed. Its showing up as changed means it's genuinely changing — maybe it's recalculated with afilterthat returns a different value — or that it isn't a primitive. You'd need to inspect the value in the Components tab. If the data really does change, that render is correct and there's nothing to fix.
And the final check, the one that closes the loop: record again. The panel should now say Did not render for the cards whose data hasn't changed. If it doesn't, the fix hasn't worked and it's back to diagnosis.
Solution 3.
// src/utils/metrics.js
const THRESHOLD_MS = 16;
const STORAGE_KEY = 'metrics:worst';
const consecutiveOverThreshold = new Map(); // id → number of commits in a row above the threshold
function readWorst() {
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '{}');
} catch {
return {}; // sessionStorage can fail in private mode
}
}
export function reportMetric(id, phase, actualDuration, baseDuration, startTime) {
// 1) Save the worst commit per zone
const worst = readWorst();
const currentWorst = worst[id]?.actualDuration ?? 0;
if (actualDuration > currentWorst) {
worst[id] = {
actualDuration: Number(actualDuration.toFixed(2)),
baseDuration: Number(baseDuration.toFixed(2)),
savings: Number((baseDuration - actualDuration).toFixed(2)),
phase,
timestamp: new Date(performance.timeOrigin + startTime).toISOString()
};
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(worst));
} catch { /* storage full or private mode: not a reason to break the app */ }
}
// 2) Only warn after TWO commits in a row above the threshold
if (actualDuration > THRESHOLD_MS) {
const streak = (consecutiveOverThreshold.get(id) ?? 0) + 1;
consecutiveOverThreshold.set(id, streak);
if (streak >= 2) {
console.warn(
`[performance] "${id}" has had ${streak} commits in a row above ` +
`${THRESHOLD_MS} ms (latest: ${actualDuration.toFixed(1)} ms, ` +
`base ${baseDuration.toFixed(1)} ms). Profile this zone.`
);
}
} else {
consecutiveOverThreshold.set(id, 0); // a fast commit resets the streak
}
}
export function getWorst() {
return readWorst(); // to dump at the end of an E2E test
}// src/routes.jsx (fragment)
{
index: true,
element: (
<Profiler id="catalogue" onRender={reportMetric}>
<CataloguePage />
</Profiler>
)
},
{
path: 'estaciones/:estacionId',
element: (
<Profiler id="station-detail" onRender={reportMetric}>
<Lazy><StationDetailPage /></Lazy>
</Profiler>
)
}Where to place it, and why not at the root:
- You wrap zones with their own meaning — the catalogue, the station detail page, the workshop panel — because the
idis what lets you attribute a problem to a specific screen. A single<Profiler id="app">would give a global number that points at nothing. - Not at the root, for three reasons:
onRenderfires on every render of the entire subtree, and at the root that's every render in the app (measurable added cost); the aggregated duration mixes work from independent zones and loses all diagnostic power; and the 16 ms threshold stops making sense once what's being measured is "the whole app" instead of one specific interaction. - The warning after two commits in a row avoids the noise of isolated spikes — an initial load, a Query revalidation — and flags only what's sustained, which is what the user actually perceives.
Conclusion
The React DevTools Profiler is the tool that turns this entire module into a verifiable procedure. Its value is answering three questions with data: what rendered, how much it cost, and why. Without the third one, the other two only feed suspicions.
The complete method, exactly as you've applied it: measure in a production build (npm run build + npm run preview, with the alias to react-dom/profiling and keep_fnames so names stay readable), because development mode doubles renders with StrictMode, adds warnings, serves unbundled modules, and drags along source maps; locate in development, quantify in production. Record a single interaction per session, with "Record why each component rendered" always on. Read the commit timeline — each bar is one application of changes to the DOM, with a budget of 16 ms per frame — use the Ranked view to know what costs the most, and the Flamegraph view to know where it sits in the tree. And never confuse "render duration" (the component and its whole subtree) with "self time" (just itself): a parent at 214 ms with 0.8 ms of its own isn't the culprit, and that distinction decides whether the fix is memoizing, virtualizing, or changing an algorithm.
The "Why did this render?" section maps exactly onto 08-01's four causes, and each one has its answer: props changed, with the name of the culprit prop → stabilize it or pass primitives (08-03); state changed → correct, unless the state is in the wrong place (08-01); context changed → split the context and stabilize its value (07-02 + 08-03), where memo is useless; the parent component rendered → memo if the component is expensive or repeated (08-02), or isolate it with children. On top of that comes "Highlight updates", the five-second diagnostic that lights up in red whatever repaints too much.
CicloUrbano's case study has walked through the entire cycle with real numbers: six commits at ~220 ms with 2,014 components typing six letters; the exact diagnosis — Props changed: (onSelect, onBook); and the three fixes applied one at a time, measuring between each step. memo alone changed nothing, and that was a valuable result, not a failure: it confirmed that memoization without stable identities is pure cost. With useCallback commits dropped to 34 ms and cards started saying Did not render; with useDeferredValue, typing stopped competing with filtering. From 220 ms to 30 ms, from 2,014 components to 14, an INP of 240 ms down to 35 ms. And the final decision, as important as the previous ones: stopping, even though there was still technical headroom, because virtualizing would have added complexity and broken accessibility in exchange for an imperceptible improvement. You also know the <Profiler> API, with its onRender and the actualDuration/baseDuration pair, which directly measures what your memoization is saving, and you know the Profiler doesn't see everything: for layout, paint, network, and real users there's the Performance tab, Lighthouse, and web-vitals.
This closes Module 8. CicloUrbano no longer repaints 2,000 cards on every keystroke, doesn't recalculate what hasn't changed, doesn't create new functions where something compares identities, doesn't download the workshop panel to show the catalogue, and — worth the most — doesn't depend on anyone guessing anything: every decision in this module rests on a reproducible number, and the discipline of reverting what doesn't improve keeps the project from filling up with decorative optimization. But notice what it took to get here: BikeList's signature changed, state got relocated, two context providers' value got rewritten, the routes got split into nine chunks, and a lazy-loading layer with its own network failures got added. Nine refactors on code that already worked, with the only check being opening the browser and looking. That doesn't scale: next time someone stabilizes a dependency and a stale value slips through, or a memo stops updating a field, nobody will find out until a user reports it. Module 9: Testing in React takes on exactly that gap: why an application with no tests can't be refactored with confidence, what's worth testing and what isn't, and how to write checks that fail when behavior changes — not when the implementation changes. The next lesson is Introduction to Testing.
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
