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

  1. The rule that governs the module: measure first
  2. The real cost of premature optimization
  3. What "slow" means in an interface: the metrics that matter to the user
  4. Why a component re-runs
  5. The ecosystem's most expensive misunderstanding
  6. Technique 1: lower state to the component that uses it
  7. Technique 2: pass children to isolate a subtree
  8. Technique 3: derive during render instead of storing and syncing
  9. Technique 4: stable keys in lists
  10. Technique 5: delay and throttle text input
  11. Technique 6: paginate and virtualize long lists
  12. Technique 7: take work off the main thread
  13. React 19's React Compiler: what it automates and what it doesn't
  14. Performance budget and workflow
  15. Map of the rest of the module

  1. 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 StrictMode runs 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 setTimeout goes 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.

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

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

  1. INP and TBT are React's territory. When typing in BikeSearch takes 400 ms to show up, that's INP, and it gets fixed with memoization, useDeferredValue, or virtualization (08-02, 08-03).
  2. FCP and LCP are mostly the bundler's territory. No memo fixes 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.

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

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

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

  1. Technique 2: pass children to isolate a subtree

When 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 without memo, without comparison, and without extra memory — just by putting the state in the right spot in the tree.

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

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

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

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

npm install @tanstack/react-virtual
// 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:

  1. count is the logical number of elements; the virtualizer never iterates over all of them.
  2. getScrollElement returns the element with overflow: auto that produces the scroll.
  3. estimateSize can be approximate: if heights vary, it's corrected by measuring with measureElement.
  4. overscan mounts a few elements outside the view so a fast scroll doesn't show gaps.
  5. The inner container has the real total height (2,000 × 120 px = 240,000 px) even though it's nearly empty.
  6. transform: translateY(...) is preferable to top because 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.

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

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

npm install --save-dev babel-plugin-react-compiler
// 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, and useCallback. 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.

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

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. It removes implicit documentation. A useMemo with explicit dependencies communicates a design intent that the code without it no longer expresses.
  6. 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

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved