Learning path · Level 13 · Tutorial 5

Reduce React Rendering Costs with Profiling

Use the React Profiler to find expensive render work, apply the smallest justified state-placement or memoization change, and verify that the interaction improves without changing its behavior.

  • Level: Advanced
  • Duration: 25 min
  • 8 steps
Start tutorial Back to the path
Reduce React Rendering Costs with Profiling

What you will go through

  1. Prepare a Repeatable React Profile Create a deterministic local Product Explorer baseline, locate React DevTools Profiler, and record one clearly bounded interaction under documented conditions. 4 min
  2. Turn Profiler Evidence into a Hypothesis Use commit-level Profiler evidence, component-tree accounting, and render reasons to form a narrow hypothesis before changing React code. 4 min
  3. Shrink Render Scope with State Locality Move private note state into its only consumer, then verify that Note editing has a narrower render scope without changing shared-state behavior. 3 min
  4. Memoize a Measured Component Boundary Apply React.memo selectively to the measured ProductSummary boundary, then reason about prop identity and updates that can bypass equal parent props. 3 min
  5. Cache Expensive Pure Work with useMemo Use useMemo to cache measured pure ranking work while preserving necessary list updates, the 50-result limit, and complete dependency handling. 3 min
  6. Stabilize Callbacks Only Where They Help Stabilize a selection callback only where a measured memoized row boundary can benefit, while preserving correct current-state updates. 3 min
  7. Verify Cost, Responsiveness, and Behavior Verify React rendering optimizations with comparable development profiles, a separate optimized-build browser check, and focused behavior tests. 4 min
  8. Make and Defend a Profiling-Based Decision Turn React profiling evidence into a small, testable optimization decision and document how you would verify it without changing behavior. 3 min

What you will learn

  • Record an interaction and identify components responsible for significant render work.
  • Reduce unnecessary render scope by relocating frequently changing state where appropriate.
  • Choose between no memoization, React.memo, useMemo, and useCallback based on measured work and dependency identity.
  • Explain why memoization does not prevent every render or replace correct state and dependency handling.
  • Compare the same interaction before and after a change and rerun relevant behavior tests.

Before you start

  • Measure Loading and Responsiveness Locally
  • Update Component State from User Events
  • Lift Shared State to a Common Parent
  • Synchronize External Systems with React Effects

Step 1 of 8

Prepare a Repeatable React Profile

Create a deterministic local Product Explorer baseline, locate React DevTools Profiler, and record one clearly bounded interaction under documented conditions.

1. Create the deterministic baseline

Create a clean local project

Create the fixture in a new local directory. The Product Explorer application itself uses no network data or external assets: it generates its deterministic 5,000-product dataset locally. The commands install the standard Vite React–TypeScript dependencies, then you will replace the generated source files with the complete sections in this step.

Keep StrictMode enabled. This baseline intentionally keeps query, selectedIds, and note in CatalogApp; do not optimize it yet.

Scaffold and install Product Explorer

Run these commands in a terminal from the parent directory where you want the project folder created.

bash
npm create vite@latest product-explorer -- --template react-ts
cd product-explorer
npm install
Expected output
Scaffolding project in ./product-explorer...

Done. Now run:

  cd product-explorer
  npm install

added 190 packages, and audited 191 packages in 12s

found 0 vulnerabilities

The npm, Node.js, Vite, package, timing, and audit messages vary by machine. The important result is a `product-explorer` directory with installed dependencies.

src/data.ts

Create src/data.ts and replace its contents with this complete deterministic data source.

typescript
export type Category = 'Books' | 'Games' | 'Tools';

export interface Product {
  readonly id: number;
  readonly name: string;
  readonly category: Category;
  readonly score: number;
}

const categories: readonly Category[] = ['Books', 'Games', 'Tools'];

export const PRODUCTS: readonly Product[] = Object.freeze(
  Array.from({ length: 5000 }, (_, index): Product => {
    const id = index + 1;

    return Object.freeze({
      id,
      name: `Product ${String(id).padStart(4, '0')}`,
      category: categories[index % categories.length],
      score: (id * 37) % 1000,
    });
  }),
);

src/catalog.ts

Create src/catalog.ts and replace its contents. rankProducts does not mutate the source array and keeps the original product object references.

typescript
import type { Category, Product } from './data';

export function rankProducts(
  products: readonly Product[],
  query: string,
): Product[] {
  const needle = query.trim().toLowerCase();
  const matches = products.filter((product) => {
    const searchable = `${product.name} ${product.category}`.toLowerCase();
    return searchable.includes(needle);
  });

  return [...matches].sort((first, second) =>
    second.score - first.score || first.id - second.id,
  );
}

export function summarizeProducts(
  products: readonly Product[],
): Record<Category, number> {
  return products.reduce<Record<Category, number>>(
    (counts, product) => ({
      ...counts,
      [product.category]: counts[product.category] + 1,
    }),
    { Books: 0, Games: 0, Tools: 0 },
  );
}

2. Run and inspect the baseline

Add the complete baseline interface

Replace src/App.tsx by pasting the following three sections consecutively, in order. Then replace src/main.tsx with the supplied file. No helpers are omitted: this is the unoptimized baseline used throughout the tutorial.

Summary and row components

src/App.tsx — part 1 of 3

tsx
import { useState } from 'react';
import { PRODUCTS, type Product } from './data';
import { rankProducts, summarizeProducts } from './catalog';

function ProductSummary({ products }: { products: readonly Product[] }) {
  const summary = summarizeProducts(products);

  return (
    <section aria-label='Product summary'>
      <h2>Product summary</h2>
      <ul>
        {(['Books', 'Games', 'Tools'] as const).map((category) => (
          <li key={category}>
            {category}: {summary[category]}
          </li>
        ))}
      </ul>
    </section>
  );
}

type ProductRowProps = {
  product: Product;
  selected: boolean;
  onToggle: (id: number) => void;
};

function ProductRow({ product, selected, onToggle }: ProductRowProps) {
  return (
    <li>
      <label>
        <input
          type='checkbox'
          checked={selected}
          aria-label={`Select ${product.name}`}
          onChange={() => onToggle(product.id)}
        />
        {product.name} — {product.category} — score {product.score}
      </label>
    </li>
  );
}

List and note components

src/App.tsx — part 2 of 3

tsx
type ProductListProps = {
  products: readonly Product[];
  selectedIds: number[];
  handleToggle: (id: number) => void;
};

function ProductList({
  products,
  selectedIds,
  handleToggle,
}: ProductListProps) {
  return (
    <ul>
      {products.map((product) => (
        <ProductRow
          key={product.id}
          product={product}
          selected={selectedIds.includes(product.id)}
          onToggle={handleToggle}
        />
      ))}
    </ul>
  );
}

type NoteEditorProps = {
  note: string;
  onChange: (value: string) => void;
};

function NoteEditor({ note, onChange }: NoteEditorProps) {
  return (
    <label>
      Private note
      <textarea
        value={note}
        onChange={(event) => onChange(event.target.value)}
      />
    </label>
  );
}

CatalogApp baseline

src/App.tsx — part 3 of 3

tsx
export default function CatalogApp() {
  const [query, setQuery] = useState('');
  const [selectedIds, setSelectedIds] = useState<number[]>([]);
  const [note, setNote] = useState('');
  const rankedProducts = rankProducts(PRODUCTS, query).slice(0, 50);

  function handleToggle(id: number) {
    setSelectedIds((current) =>
      current.includes(id)
        ? current.filter((selectedId) => selectedId !== id)
        : [...current, id],
    );
  }

  return (
    <main>
      <h1>Product Explorer</h1>
      <label>
        Search products
        <input
          value={query}
          onChange={(event) => setQuery(event.target.value)}
        />
      </label>
      <NoteEditor note={note} onChange={setNote} />
      <p aria-live='polite'>Selected products: {selectedIds.length}</p>
      <ProductSummary products={PRODUCTS} />
      <ProductList
        products={rankedProducts}
        selectedIds={selectedIds}
        handleToggle={handleToggle}
      />
    </main>
  );
}

src/main.tsx

Replace src/main.tsx with this file. Keeping StrictMode is deliberate for this development baseline.

tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import CatalogApp from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <CatalogApp />
  </StrictMode>,
);

Run the fixture

Start the local development server from the product-explorer directory, then open the Local address printed in your terminal.

bash
npm run dev
Expected output
> product-explorer@0.0.0 dev
> vite

  VITE v6.0.0  ready in 180 ms

  ➜  Local:   http://localhost:5173/
  ➜  press h + enter to show help

The package name, Vite version, and port can differ. Use your own terminal's Local URL.

Tip

Check the expected interface

Confirm the visible baseline before profiling:

  • An input labeled Search products is visible.
  • A textarea labeled Private note is visible.
  • The status starts as Selected products: 0.
  • The list shows exactly 50 checkbox rows, each with an accessible name such as Select Product 0001.

If any check fails, correct the local source before recording. The 50-row limit is part of this fixture, even though ranking reads all 5,000 products.

3. Know what the recording measures

Locate React DevTools Profiler

Install the official React Developer Tools extension for your browser if it is not installed. With the local application open, open browser DevTools and locate the React DevTools Profiler panel. Its placement and control names can vary by browser and extension version.

A Profiler recording contains React commits during a bounded period. It is evidence about component render work; it is not a replacement for the browser Performance panel.

Render, commit, and paint

Diagram of a React component tree being rendered, a small DOM update being committed, and a separate later browser paint.

The render phase calculates component output. The commit phase applies necessary DOM changes. A component can render even when React finds no DOM mutation to make; browser paint is a separate concern.

Use precise terms

During the render phase, React calculates what component output should be. During the commit phase, React applies the necessary DOM changes. Therefore, a component function running does not automatically mean that the DOM changed. Neither event proves that pixels have painted on screen.

Keep this distinction when reading a recording: render duration is React component work, not a whole-interaction timing and not INP.

Tip

Keep setup outside the capture

Keep each capture narrow: first reset the scenario, perform its interaction once without recording to warm up, then reset to the same starting state. Only then start recording, perform the stated action, and stop.

Do not substitute console.log render counts for Profiler evidence. Development checks under StrictMode can repeat component execution. Record that Strict Mode is enabled instead of interpreting repeated console output as measured cost.

4. Define repeatable evidence

Choose one bounded scenario

Use the same fixed scenario every time. Reset before each recording. Setup actions—including entering an initial query for Selection—stay outside the recorded interaction.

Fixed interaction scenarios

Keep development Profiler records separate from later optimized-build browser checks.

Only the action in the final column belongs inside each React DevTools Profiler recording.
ScenarioStarting stateRecorded action
Note editingEmpty note; empty searchType `review` into Private note
FilteringEmpty search; empty noteType `tools` into Search products
SelectionSearch set to `tools`Toggle the first visible product twice

Evidence log conditions

Write concrete values from your own computer beside every baseline capture.

These conditions make a later before-and-after comparison reproducible.
Evidence itemWhat to record
Data size5,000 products
Browser and extensionBrowser name/version; React Developer Tools version
Build modeDevelopment
React checksStrict Mode enabled
Compiler statusReact Compiler not used
Recording setupViewport and any CPU throttling
ScenarioFixed scenario and recorded action

Sequence the capture

Put the recording workflow in order.

  1. Perform only the specified interaction.
  2. Stop recording and inspect the resulting commits.
  3. Warm up with the scenario action without recording, then reset to its starting state.
  4. Start the Profiler recording.

5. Report the bounded capture

Document your first baseline

Capture one fixed scenario locally, then write a compact baseline log. Include the workflow in order; whether a component render automatically means a DOM mutation and why; the scenario captured; and your browser/version, extension version, build mode, Strict Mode status, compiler status, data size, viewport, and throttling setting.

Write at least 180 characters (0/180).

Step 2 of 8

Turn Profiler Evidence into a Hypothesis

Use commit-level Profiler evidence, component-tree accounting, and render reasons to form a narrow hypothesis before changing React code.

1. Isolate relevant commits

Select the commits that answer your question

A Profiler recording is a sequence of commits, not one score for the entire trace. After recording one bounded scenario, locate the commits caused by the action itself. Ignore setup, initial mounting, and any warm-up work outside that action.

For example, a Note editing recording may contain several commits as the learner types. Inspect the commits associated with those keystrokes individually. A single unusually expensive commit can matter more than a low average that hides it.

A commit’s render duration describes component rendering associated with that commit. It is not a measurement of the whole interaction.

Bound the evidence

A profiling timeline highlights a small cluster of commits connected to a component subtree.

Start with the commits produced by one recorded action, rather than combining unrelated work across the entire recording.

2. Find meaningful work in the tree

Read tree cost without double-counting

Use the flamegraph and ranked views together. The flamegraph shows where costly work sits in the component tree; the ranked view helps surface components contributing substantial render work. Exact controls and labels can vary by DevTools version.

Do not add a parent’s subtree duration to durations of descendants inside that subtree. Descendant work is already included. Compare either non-overlapping subtrees or a component’s own work at one chosen level of the tree.

Illustrative: Product Explorer commit

Illustrative render-duration accounting for one selected commit. A subtree total already contains its descendants.

Compare non-overlapping targets; do not sum nested subtree totals.
ElementOwn workDescendant workSubtree total
CatalogApp0.9 ms28.0 ms28.9 ms
ProductList1.6 ms18.0 ms19.6 ms
50 ProductRow instances18.0 ms0.0 ms18.0 ms
ProductSummary8.4 ms0.0 ms8.4 ms

Tip

Choose one accounting level

Here, ProductList’s 19.6 ms subtree already includes the 18.0 ms from the ProductRow instances. Adding those numbers would falsely report 37.6 ms.

A useful first comparison is ProductList’s 19.6 ms subtree versus its sibling ProductSummary at 8.4 ms. Drill into ProductList only if the rows or the list’s own work need separate investigation.

3. Turn observations into hypotheses

Record why components rendered

Before a new recording, enable render-reason recording if your React DevTools version provides it. Then capture the same bounded scenario again. A reason such as state changed, props changed, context changed, or parent rendered is evidence about a trigger—not proof that the render was wasteful.

Do not infer a cause from flamegraph color alone. Combine the recorded reason, the component’s duration, and the source relationship you expect.

Connect a reason to a falsifiable hypothesis

These are candidate explanations to test, not conclusions to optimize immediately.

A useful hypothesis predicts what a comparable recording should show.
ScenarioEvidence to inspectCandidate causeTestable prediction
Note editingCatalogApp state update; unrelated branches participateFrequently edited note state may be owned too broadlyProductList and ProductSummary appear during note typing although their inputs are unchanged
Filteringquery state changes in CatalogApp; ranking creates a new products array; ProductList reports changed products propRanking work may be substantial, but a changed query legitimately requires new resultsRanking runs for the changed query and ProductList receives the newly ranked products array; first establish whether the work is significant
SelectionProducts and query are unchanged while ranking runs againA repeated pure calculation may run during selectionThe same ranking work appears on selection commits despite unchanged ranking inputs
SelectionUnchanged rows report an onToggle prop changeA new function reference may defeat a row boundaryMany unchanged rows participate when the handler identity changes

Tip

Keep the claim narrow

A frequent render is not automatically expensive, and an expensive render is not automatically avoidable. State, prop, context, and parent-related reasons help form a narrow claim that a later recording can confirm or reject.

Warning

Know the boundary of the evidence

Profiler commit data does not include all browser work. It does not measure every effect, layout, paint, or every render attempt, and its render duration is not whole-interaction timing. Use the browser Performance panel separately when you need browser-level evidence; do not treat the two measurements as interchangeable.

4. Practice: explain the recording

Form an evidence-based hypothesis

Interpret this annotated, Illustrative Selection commit:

  • CatalogApp: reason — selectedIds state changed; own work — 0.8 ms.
  • ProductList: reason — parent rendered; own work — 1.5 ms.
  • 50 ProductRow instances: reason — onToggle prop changed; combined own work — 17.8 ms.
  • ProductList subtree total — 19.3 ms.
  • ProductSummary: reason — parent rendered; own work — 8.4 ms.

Name one significant target without double-counting nested work. Then state one specific, falsifiable hypothesis that connects an observed render reason to why that target participated in this Selection commit. Describe what a comparable later recording would need to show to support the hypothesis.

Write at least 120 characters (0/120).

Step 3 of 8

Shrink Render Scope with State Locality

Move private note state into its only consumer, then verify that Note editing has a narrower render scope without changing shared-state behavior.

Predict the render scope

Predict the narrower update

A state update starts at the component that owns that state. If CatalogApp owns the private note, every keystroke makes CatalogApp participate, bringing its sibling branches into the render scope.

note is consumed only by NoteEditor, so NoteEditor is its narrowest valid owner. In contrast, query coordinates the search control and product ranking, while selectedIds coordinates the visible list and the Selected products status. Keep those shared values in CatalogApp.

Do not push every value downward by default. Place state at the lowest boundary that must coordinate all of its consumers.

Before and after state ownership

A comparison of a broad note update from CatalogApp and a local note update contained within NoteEditor.

After the move, a Note editing update should be contained within NoteEditor rather than starting at CatalogApp.

Apply the smallest ownership change

Move the single source of truth

Make one ownership change in src/App.tsx: replace the old NoteEditorProps type and prop-based NoteEditor definition with this local-state component. The existing named useState import remains valid.

This keeps one authoritative note value. Do not create a second copy in CatalogApp and synchronize it with an Effect. Because NoteEditor stays mounted while searching or selecting, its note remains present during those interactions. If the application is unmounted and mounted again, the editor starts empty, as it did before.

Replace the NoteEditor definition

tsx
function NoteEditor() {
  const [note, setNote] = useState('');

  return (
    <label>
      Private note
      <textarea
        value={note}
        onChange={(event) => setNote(event.target.value)}
      />
    </label>
  );
}

Apply the matching CatalogApp edits

tsx
// Delete this state line from CatalogApp.
const [note, setNote] = useState('');

// Replace the existing prop-based element with this element.
<NoteEditor />

Tip

Preserve shared-state behavior

In the baseline shown earlier, the element being replaced is <NoteEditor note={note} onChange={setNote} />. Remove that whole element, including both props, and insert <NoteEditor /> in the same position.

Keep query and selectedIds as one authoritative value each in CatalogApp. Copying either into independent state would require synchronization and could make the search results, selected count, and row checkboxes disagree.

Check the implementation

Complete the local owner

Complete the local state initializer in NoteEditor.

tsx
function NoteEditor() {
  const [note, setNote] = ('');

  return (
    <label>
      Private note
      <textarea
        value={note}
        onChange={(event) => setNote(event.target.value)}
      />
    </label>
  );
}

Document the evidence

Re-record the bounded interaction

Warm up without recording, then restore the fixed Note editing starting state: the note is empty. In React DevTools Profiler, start a fresh recording only after setup. Type review into Private note, then stop recording.

Inspect commits caused by those keystrokes. The prediction is that NoteEditor participates, while CatalogApp, ProductSummary, ProductList, and ProductRow do not participate because of this note update. Record Filtering and Selection separately when needed; moving note state does not itself predict a reduction for those scenarios.

Report the state-locality result

After making the change and recording Note editing locally, write a compact evidence report. Include:

  1. The ownership change you completed.
  2. Why query and selectedIds must remain shared in CatalogApp, rather than being copied into independent state.
  3. Whether the components participating in the new profile matched your predicted subtree.

State your actual observation rather than inventing a duration or result.

Write at least 220 characters (0/220).

Step 4 of 8

Memoize a Measured Component Boundary

Apply React.memo selectively to the measured ProductSummary boundary, then reason about prop identity and updates that can bypass equal parent props.

Skip work only at the measured boundary

A boundary, not a render ban

React.memo is a component-boundary optimization. When a parent updates, React can skip rendering a memoized child when every received prop compares equal to its previous value.

This is an opportunity to skip parent-driven work, not a promise that a component will never render. In Product Explorer, Filtering changes query in CatalogApp, while ProductSummary continues to receive the same immutable module-level PRODUCTS reference. Earlier profiling made this summary a candidate worth testing.

State locality remains the better first response when a frequently updated value is private to one component. Memoization is justified only when the measured boundary has meaningful work to avoid.

How the summary boundary can help

A component tree where a changing search update reaches a parent while ProductSummary receives the same products array reference and can be skipped at its boundary.

During Filtering, the summary receives the unchanged PRODUCTS reference although its parent updates.

Identity determines the default comparison

What equal props means

By default, memo compares every prop with Object.is. It does not inspect the contents of an array, object, or function. Equal-looking values are not enough: their identities must compare equal.

A memoized component can still render when its own state changes or when a context value it consumes changes, even if its parent passes equal props. Use relevant Profiler commits and render reasons to check whether the boundary actually avoided useful work.

Identity cases

Each row describes the default comparison during a parent-driven update.

`Object.is` is checked per prop; React does not perform a deep comparison.
Prop on two rendersIdentity resultEffect on the memo boundary
`PRODUCTS` reusedSame array referenceEligible to skip
`4` then `4`Same primitive valueEligible to skip
New `{ category: 'Tools' }` each timeDifferent object referencesNot eligible from that prop
New function each timeDifferent function referencesNot eligible from that prop

Warning

Stable is not permission to mutate

Do not mutate PRODUCTS or one of its product records to preserve identity. A stable reference after mutation can let React skip rendering even though the displayed data should have changed. The source array and its records remain immutable; identity is useful only when it represents correct data.

Measure the memoized change

Apply the smallest change

Make only the following incremental edits in src/App.tsx. Preserve the existing ProductSummary markup, the local NoteEditor state from the previous step, and all of CatalogApp except its one summary element. Do not change the ranking call, list, search, note, or selection behavior.

The wrapper creates a memoized version of the existing component. Its products prop remains the same stable PRODUCTS reference.

Memoize the existing ProductSummary boundary

src/App.tsx — make these three targeted edits to the existing file

tsx
// 1. Replace the existing React import.
import { memo, useState } from 'react';

// 2. Insert this immediately after the existing ProductSummary function.
const MemoizedProductSummary = memo(ProductSummary);

// 3. In the existing CatalogApp return value, replace only this element:
// <ProductSummary products={PRODUCTS} />
<MemoizedProductSummary products={PRODUCTS} />

Tip

Test the boundary hypothesis

Re-record Filtering: reset Search products to empty, warm up outside the recording, reset it again, then record typing tools. Inspect only commits caused by that action.

Check whether ProductSummary is absent from parent-driven render work or has a reduced contribution. The prop comparison also costs work, so leave a cheap summary—or a boundary whose props usually change—unmemoized when repeated recordings do not show meaningful avoided work.

Apply and classify the boundary

Complete the summary boundary

Complete the declaration inserted after the existing ProductSummary function.

tsx
const MemoizedProductSummary = ;

// The existing CatalogApp return value uses:
<MemoizedProductSummary products={PRODUCTS} />

Classify render cases

Match each case to its effect on a React.memo boundary.

Tap an item and then its matching pair.

Step 5 of 8

Cache Expensive Pure Work with useMemo

Use useMemo to cache measured pure ranking work while preserving necessary list updates, the 50-result limit, and complete dependency handling.

Evidence: selection changes unrelated state

Find repeated calculation work

Start with evidence, not the presence of a large list. In the Selection scenario, reset Search products to tools, warm up outside the recording, reset again, then record the two checkbox toggles in React DevTools Profiler.

Inspect the relevant commits and the source. rankProducts(PRODUCTS, query) reads the product collection and query; it does not read selectedIds. Before caching, a CatalogApp render caused by selection can repeat filtering and sorting 5,000 products although the ranking inputs are unchanged.

That makes a narrow hypothesis: reuse the pure, already-limited visible-product calculation during Selection without preventing selection state from updating the list.

Ranking inputs determine reuse

Diagram showing product data and a search query feeding a cached ranking-and-limit calculation, while a selection update reaches the list separately.

Selection can reuse the visible-product result when ranking inputs are unchanged; a query change requires a new result.

Add a narrow cache and preserve behavior

Cache the visible ranking result

Cache the complete visible-result calculation inside CatalogApp. Add useMemo to the existing named React import, replace the direct ranking expression, and pass visibleProducts to ProductList.

The calculation still ranks all matching products, then non-mutatingly keeps only the first 50. It preserves the existing interface behavior and product object references. Its dependencies are every reactive value read by the calculation: PRODUCTS and query. React compares dependencies with Object.is.

During Selection, CatalogApp can render because selectedIds changed, but unchanged PRODUCTS and query allow React to reuse visibleProducts. During Filtering, query changes, so the cache invalidates and the correct first 50 ranked matches are calculated again.

Update CatalogApp without changing the 50-row limit

In src/App.tsx, replace the current React import and the direct rankedProducts calculation. Then replace the existing ProductList prop value with visibleProducts.

tsx
import { memo, useMemo, useState } from 'react';

// Inside CatalogApp, replace:
// const rankedProducts = rankProducts(PRODUCTS, query).slice(0, 50);
const visibleProducts = useMemo(
  () => rankProducts(PRODUCTS, query).slice(0, 50),
  [PRODUCTS, query],
);

// In the existing ProductList element, replace only this prop:
<ProductList
  products={visibleProducts}
  selectedIds={selectedIds}
  handleToggle={handleToggle}
/>

Tip

Keep necessary list updates

useMemo does not memoize CatalogApp or ProductList. A Selection update still changes selectedIds, so ProductList can render and the toggled checkbox must receive its new selected value. The optimization only avoids rerunning rankProducts(...).slice(0, 50) when its inputs compare equal.

Practice: preserve complete dependencies

Warning

A cache is not authoritative state

A memoized calculation must remain correct whenever it runs. Keep rankProducts pure: do not add state updates, Effects, mutations, or correctness-critical side effects to its calculation.

A freshly created array or object used as a dependency compares unequal even if its contents look identical, causing unnecessary invalidation. The opposite mistake is more dangerous: omitting a value that the calculation reads can reuse an obsolete result. React may also discard a memoized value, so the app must be correct if this calculation runs again.

Complete the ranking cache

Complete the cache that calculates the same first 50 visible matches as the original expression.

Name every ranking input

Fill both dependency positions.

tsx
const visibleProducts = useMemo(
  () => rankProducts(PRODUCTS, query).slice(0, 50),
  [, ],
);

Apply the cache rules to all fixed scenarios

Make the dependency decision

Assume the private note state is already local to NoteEditor. A developer proposes this cache:

const visibleProducts = useMemo(
  () => rankProducts(PRODUCTS, query).slice(0, 50),
  [PRODUCTS],
);

Write exactly three concise bullets:

  1. The corrected complete dependency list.
  2. For Note editing, Filtering, and Selection, state whether CatalogApp evaluates this cache and whether the visible-product calculation is reused or invalidated.
  3. Why the proposed list must be rejected even if one recording appears faster.

Write at least 180 characters (0/180).

Step 6 of 8

Stabilize Callbacks Only Where They Help

Stabilize a selection callback only where a measured memoized row boundary can benefit, while preserving correct current-state updates.

See why callback identity reaches every row

A new function prop can defeat a row boundary

memo(ProductRow) can skip parent-driven rendering only when its relevant props compare equal. A callback declared again during every CatalogApp render is a new function reference, even if it would perform the same selection update.

In the Selection scenario, immutable product objects and an unchanged row’s selected value can remain stable. If onToggle also remains stable, unchanged memoized rows become eligible to skip parent-driven work. The toggled row receives a different selected value and must render so its checkbox stays correct.

This is a measured optimization: use it only if your Selection profile identifies meaningful row work to avoid.

Stable versus recreated callback identity

Comparison showing a recreated callback reaching every memoized product row versus a stable callback with only the toggled row changing.

A stable callback removes one prop-identity reason for unchanged memoized rows to render; it does not guarantee a skip.

Apply a compile-ready callback boundary

Preserve identity without capturing old selection

useCallback reuses a function reference while its dependencies compare equal. It does not cache execution: when a checkbox change calls onToggle, the callback body still runs and updates selection.

Use a functional setSelectedIds update so handleToggle receives the current selection as current; it does not need to capture selectedIds. Therefore this callback can use []. If the callback reads any other reactive value, include that value in its dependency list.

Keep the stable function at the memoized row boundary. Each row may still create its small event handler to supply its own product ID.

Replace the row and wire the callback through ProductList

In src/App.tsx, make these replacements in the existing file. handleToggle is inside CatalogApp, where its Hook and setSelectedIds are valid. This assumes the visibleProducts calculation from the previous step remains in CatalogApp.

tsx
// 1. Replace the existing React import.
import { memo, useCallback, useMemo, useState } from 'react';

// 2. Replace the existing ProductRow function with this declaration.
const ProductRow = memo(function ProductRow({
  product,
  selected,
  onToggle,
}: ProductRowProps) {
  return (
    <li>
      <label>
        <input
          type='checkbox'
          checked={selected}
          aria-label={`Select ${product.name}`}
          onChange={() => onToggle(product.id)}
        />
        {product.name} — {product.category} — score {product.score}
      </label>
    </li>
  );
});

// 3. Replace ProductListProps and ProductList with these versions.
type ProductListProps = {
  products: readonly Product[];
  selectedIds: number[];
  onToggle: (id: number) => void;
};

function ProductList({
  products,
  selectedIds,
  onToggle,
}: ProductListProps) {
  return (
    <ul>
      {products.map((product) => (
        <ProductRow
          key={product.id}
          product={product}
          selected={selectedIds.includes(product.id)}
          onToggle={onToggle}
        />
      ))}
    </ul>
  );
}

// 4. Inside CatalogApp, after visibleProducts and before return, replace
// the existing handleToggle function with this Hook call.
const handleToggle = useCallback((id: number) => {
  setSelectedIds((current) =>
    current.includes(id)
      ? current.filter((selectedId) => selectedId !== id)
      : [...current, id],
  );
}, []);

// 5. In CatalogApp's existing ProductList element, use:
<ProductList
  products={visibleProducts}
  selectedIds={selectedIds}
  onToggle={handleToggle}
/>

Tip

Test the specific row-work hypothesis

Re-record Selection after this one change: start with Search products set to tools, warm up outside the capture, reset to that state, then toggle the first visible product twice. Check whether unchanged ProductRow instances avoid meaningful render work while the toggled row still participates. The 50-row limit, category and score text, accessible names, and selection behavior must remain unchanged.

Warning

Identity stability has a maintenance cost

Do not add useCallback by default. A callback passed only to a cheap, non-memoized consumer has no React.memo prop-comparison benefit. Retain memo(ProductRow) and useCallback only when comparable profiles show meaningful avoided work.

An empty dependency list is valid here because the functional updater supplies current selection. It is never a way to conceal another reactive value read by the callback; that would create a stale closure.

Practice the identity-preserving wiring

Complete the stable selection wiring

Complete the stable callback and its forwarding through the list.

tsx
const handleToggle = ((id: number) => {
  setSelectedIds((current) =>
    current.includes(id)
      ? current.filter((selectedId) => selectedId !== id)
      : [...current, id],
  );
}, );

function ProductList({ products, selectedIds, onToggle }: ProductListProps) {
  return (
    <ul>
      {products.map((product) => (
        <ProductRow
          key={product.id}
          product={product}
          selected={selectedIds.includes(product.id)}
          onToggle={}
        />
      ))}
    </ul>
  );
}

<ProductList
  products={visibleProducts}
  selectedIds={selectedIds}
  onToggle={}
/>

Review a callback optimization

Defend or reject the callback optimization

Review this proposed change for the memoized ProductRow boundary:

const handleToggle = useCallback((id: number) => {
  setSelectedIds(
    selectedIds.includes(id)
      ? selectedIds.filter((selectedId) => selectedId !== id)
      : [...selectedIds, id],
  );
}, []);

<ProductList onToggle={(id) => handleToggle(id)} />

Write a short review with four parts:

  1. Replace handleToggle and the ProductList prop line with correct code.
  2. Explain why useCallback does not cache function execution.
  3. Identify the stale-closure bug.
  4. State whether to use useCallback for a callback passed only to a cheap, non-memoized consumer, and justify the decision from measured work.

Write at least 260 characters (0/260).

Step 7 of 8

Verify Cost, Responsiveness, and Behavior

Verify React rendering optimizations with comparable development profiles, a separate optimized-build browser check, and focused behavior tests.

Use comparable Profiler evidence

Make the development-profile comparison fair

Treat an optimization as a controlled experiment, not a one-off fast recording. Change one hypothesis at a time. For every relevant scenario, record at least three runs before and three runs after the change.

Keep the data, scenario starting state, browser and extension versions, build mode, React DevTools settings, viewport, CPU throttling, and Strict Mode status identical. Reset before every recording; setup and warm-up actions remain outside the recording.

In React DevTools Profiler, compare the commits caused by the interaction, the participating components, and one consistently defined render-duration summary. For example, calculate the median relevant-commit render duration for each run, then report the range across the three run medians.

A render duration measures React render work in a commit. It is not whole-interaction latency, browser paint time, or INP.

Compare like with like

A controlled experiment with repeated matching development Profiler recordings before and after a code change, plus a separate browser timeline for an optimized build.

Keep development Profiler evidence separate from the optimized-build browser check.

Illustrative development-profile comparison

Illustrative. Each row is a separate, one-change development-profile comparison. Every value uses the same summary: median relevant-commit render duration, with the range across three run medians. These are React render-duration summaries, not whole-interaction timings.

Illustrative development Profiler results; do not compare these values directly with browser Performance panel timings.
ScenarioBuild modeBeforeAfterDecision
Note editingDevelopment Profiler, Strict Mode8.4 ms (8.0–9.2)2.9 ms (2.6–3.3)Keep state locality
FilteringDevelopment Profiler, Strict Mode15.1 ms (14.5–15.8)15.0 ms (14.3–15.9)Investigate; no measured gain
SelectionDevelopment Profiler, Strict Mode13.8 ms (13.0–14.4)5.2 ms (4.9–5.6)Keep if behavior passes

Run a separate optimized-build check

Check the optimized build separately

Development profiling and an optimized-build interaction check answer different questions. Keep React DevTools Profiler comparisons development-to-development, with the same Strict Mode setting and profiling overhead. Strict Mode can repeat development checks, and profiling itself adds overhead.

After the comparable profile runs, build locally and inspect the same fixed scenario separately with the browser Performance panel. Record the user action, then inspect its event work, main-thread activity, and presentation-related activity. This can support a local interaction observation, but it does not establish an INP claim.

Use the same data, starting state, viewport, and throttling. A normal optimized build does not provide the same Profiler instrumentation by default, so never compare a development Profiler render duration directly with a browser timing from preview.

Warning

Avoid mixed-mode conclusions

Do not conclude that “render duration fell from 8.4 ms to a 5.1 ms production interaction.” Those numbers come from different tools, modes, and scopes. Report the development render-duration comparison and the optimized-build browser observation as separate results.

Build and preview locally

Build the local optimized bundle, then serve it locally. Open the Local address printed by preview and record the same fixed scenario in the browser Performance panel.

bash
npm run build
npm run preview
Expected output
> product-explorer@0.0.0 build
> tsc -b && vite build

vite v6.0.0 building for production...
✓ built in 1.02s

> product-explorer@0.0.0 preview
> vite preview

  ➜  Local:   http://localhost:4173/
  ➜  press h + enter to show help

Exact Vite versions, asset names, build duration, and preview address vary. Keep the preview server running while you make the separate browser recording.

Rerun focused behavior checks

Test behavior, not render counts

Behavior checks protect the user task while you evaluate cost. The supplied tests verify query-dependent catalog results, selection and deselection, note independence, keyboard checkbox operation, the required accessible checkbox names, focus continuity, and visible selection state.

They deliberately do not assert render counts or timing thresholds. Those are implementation details that vary by machine and mode. The files below run against the cumulative Product Explorer fixture after the state-locality and memoization changes from the preceding steps.

Configure the local test environment

Create these two files. They configure Vitest with jsdom and load the Testing Library matchers used by the focused suite.

typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/setupTests.ts'],
  },
});

// src/setupTests.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(cleanup);

Add focused behavior tests

Create src/App.test.tsx. The deterministic ranking places Product 1027 — Books — score 999 among the first 50 Books results, while Product 0027 — Tools — score 999 is excluded by that query.

tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import CatalogApp from './App';

describe('CatalogApp behavior', () => {
  it('shows query-dependent catalog results', async () => {
    const user = userEvent.setup();
    render(<CatalogApp />);
    const search = screen.getByLabelText('Search products');

    expect(
      screen.getByText('Product 0027 — Tools — score 999'),
    ).toBeInTheDocument();

    await user.type(search, 'books');

    expect(search).toHaveValue('books');
    expect(screen.getAllByRole('checkbox')).toHaveLength(50);
    expect(
      screen.getByText('Product 1027 — Books — score 999'),
    ).toBeInTheDocument();
    expect(
      screen.queryByText('Product 0027 — Tools — score 999'),
    ).not.toBeInTheDocument();

    await user.clear(search);

    expect(
      screen.getByText('Product 0027 — Tools — score 999'),
    ).toBeInTheDocument();
  });

  it('selects and deselects with the keyboard', async () => {
    const user = userEvent.setup();
    render(<CatalogApp />);
    const checkbox = screen.getByRole('checkbox', {
      name: 'Select Product 0027',
    });

    checkbox.focus();
    await user.keyboard('{Space}');

    expect(checkbox).toHaveFocus();
    expect(checkbox).toBeChecked();
    expect(screen.getByText('Selected products: 1')).toBeInTheDocument();

    await user.keyboard('{Space}');

    expect(checkbox).toHaveFocus();
    expect(checkbox).not.toBeChecked();
    expect(screen.getByText('Selected products: 0')).toBeInTheDocument();
  });

  it('preserves filtering and selection while a private note changes', async () => {
    const user = userEvent.setup();
    render(<CatalogApp />);

    const search = screen.getByLabelText('Search products');
    await user.type(search, 'books');
    const checkbox = screen.getByRole('checkbox', {
      name: 'Select Product 1027',
    });
    await user.click(checkbox);

    const note = screen.getByLabelText('Private note');
    await user.type(note, 'review');

    expect(note).toHaveFocus();
    expect(note).toHaveValue('review');
    expect(search).toHaveValue('books');
    expect(checkbox).toBeChecked();
    expect(screen.getByText('Selected products: 1')).toBeInTheDocument();
    expect(
      screen.getByText('Product 1027 — Books — score 999'),
    ).toBeInTheDocument();
  });
});

Run the regression checks

Install the local test dependencies if they are not already present, then run the focused suite once. Investigate a failing behavior check before keeping an optimization.

bash
npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
npm exec vitest run
Expected output
 RUN  v3.0.0 /your-local-product-explorer

 ✓ src/App.test.tsx (3 tests) 184ms

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Start at  10:24:16
   Duration  1.12s

Package-manager progress and Vitest durations vary. The important result is that all three tests pass.

Report evidence without mixing measurements

Make a defensible verification decision

A teammate changed only note-state placement and claims the interaction improved by comparing a React DevTools development Profiler render duration of 8.4 ms with a browser Performance panel timing of 5.1 ms from local preview.

After running the supplied behavior checks locally, write a concise verification record. Reject or accept that numeric comparison and explain why. Then provide a valid verification sequence for the change: comparable development-profile runs, a separate optimized-build browser check, and the targeted behavior checks. Report your actual local test outcome and measurements or explicitly state what you did not measure.

Finish with keep, revert, or investigate. Distinguish render duration from whole-interaction timing, and do not call either local measurement INP.

Write at least 300 characters (0/300).

Step 8 of 8

Make and Defend a Profiling-Based Decision

Turn React profiling evidence into a small, testable optimization decision and document how you would verify it without changing behavior.

Build a defensible chain of evidence

From trace to decision

A profiling decision is defensible when every link is explicit:

  1. Locate significant work in relevant Profiler commits.
  2. Explain the trigger with render reasons and component relationships.
  3. Choose the smallest change that addresses that trigger—or choose no change.
  4. Predict and verify the affected scenario with comparable recordings and behavior checks.

Do not optimize because a component rendered. Optimize when the measured work, its cause, and a safe intervention form one testable claim.

The evidence chain

A four-stage visual flow from inspecting a React profile, to identifying an update path, to making one small component change, to comparing results and checking behavior.

Use this order: evidence, cause, smallest intervention, then verification.

Tip

Keep measurements in their proper context

A React DevTools Profiler render duration measures component render work in recorded commits. It is not a whole-interaction browser timing and it is not INP. Keep development-profile comparisons like-for-like; use a separate optimized-build check in the browser Performance panel for the user interaction.

Read a case without overclaiming

Case evidence: a private note causes broad participation

Consider this compact Product Explorer case. In the Note editing scenario, the learner resets the note to empty, warms up the app, then records typing review. The Profiler shows a state update in CatalogApp; NoteEditor, ProductSummary, ProductList, and 50 visible ProductRow components participate because their parent rendered.

The note is private: no sibling reads it. The products reference, query, selection, and summary inputs are unchanged. Render reasons support the update path; color alone would not be enough evidence.

Illustrative comparison

The following supplied measurements are Illustrative development Profiler summaries. They compare the same scenario, data, starting state, tool settings, and Strict Mode status. They do not represent optimized-build browser timing.

Illustrative development-profile evidence for one narrowly scoped change.
ScenarioBuild modeBeforeAfterDecision
Note editingDevelopment Profiler, Strict Mode onIllustrative: 9.8 ms median render duration; app, editor, summary, list, and 50 rows participateIllustrative: 1.4 ms median render duration; only the editor branch participatesKeep if behavior checks also pass
FilteringDevelopment Profiler, Strict Mode onIllustrative: 11.2 ms median render durationNot measured for this changeDo not claim an improvement
SelectionDevelopment Profiler, Strict Mode onIllustrative: 10.7 ms median render durationNot measured for this changeDo not claim an improvement

Warning

Interpret the case carefully

The behavior evidence must match the change. For this case, verify that typing still displays and preserves the private note, filtering still returns query-dependent results, and selecting then deselecting a product still updates the visible selection. A lower duration without preserved behavior is not a successful optimization.

Choose the intervention that fits the cause

Use a report that can be audited

A concise evidence report contains six parts:

  • Scenario and starting state
  • Observed render evidence and trigger
  • Hypothesis
  • Smallest intervention
  • Comparable before/after result or missing evidence
  • Behavior checks and decision: keep, revert, or investigate

Name what was measured. If an optimized-build interaction check or an after recording is missing, say so; do not fill the gap with an invented number.

Review: match cause to response

Match each observation to the smallest justified response.

Tap an item and then its matching pair.

Submit a decision supported by evidence

Final application: defend one decision

Create a concise local evidence report for this supplied decision.

You recorded Note editing with an empty note and otherwise unchanged Product Explorer state. The development Profiler shows CatalogApp updating because of note state, followed by participation from NoteEditor, ProductSummary, ProductList, and 50 visible rows. The note has no consumer outside NoteEditor. A first behavior check confirms that typing displays the note.

Select the smallest first intervention. In your report, explain why at least two alternatives are unnecessary or unsafe from this evidence. State the scenario, hypothesis, intervention, prediction, comparable measurement plan or results, behavior checks, and a Keep, Revert, or Investigate decision. If you have not completed the after-profile and separate optimized-build interaction check locally, legitimately conclude Investigate rather than inventing results.

Write at least 400 characters (0/400).

Tutorial completed

Congratulations! You completed: Reduce React Rendering Costs with Profiling

Completed. A successful React optimization preserves the user task and has evidence for its cost reduction—not merely more memoized components or fewer renders.

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate