100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
React 19 & Ecosystem
50 minintermediate

Performance and Testing Practice

What You'll Build

You will take a deliberately slow, untested component — a large filterable list — and both optimise and test it, applying this module's skills end to end. You will profile to find the wasted re-renders, apply targeted memoisation and code splitting to fix them, then write React Testing Library tests (with Vitest) that verify the behaviour still works, proving your optimisations did not break anything.

The exercise mirrors real engineering: performance work and testing go hand in hand, because the tests give you the confidence to refactor for speed without fear of regressions. You will practise the measure-optimise-verify loop with the Profiler and the behaviour-focused testing approach, the two halves of professional React quality work.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as a team first drills its fundamental skills — a clean cover drive, a tidy pickup-and-throw, a reliable catch — before combining them into match play, you first build clean, reusable components before composing them into features. The insight is that mastering the fundamentals in isolation makes the combined performance solid: a team with grooved basics plays fluent cricket, exactly as an app of well-built components composes into a fluent UI.

Prerequisites

  • Completion of lessons 21–24, or equivalent familiarity with memoisation, code splitting, the Profiler, and React Testing Library.
  • A React 19 + Vite project with Vitest and React Testing Library installed and configured (jsdom environment).
  • React DevTools installed in the browser for profiling.
  • A sample dataset large enough to make re-render cost visible (e.g. a few thousand items).
  • Comfort writing components, hooks, and basic tests.

Setup & Project Structure

Start from a provided slow component: a SearchableList that renders a large list and a search input, where typing in the input re-renders every row even though the rows have not changed. Set up Vitest and React Testing Library so you can write tests, and open the React DevTools Profiler to record the wasted renders before optimising.

Analogy🏏Cricket
🏏 Think of it like cricket: before a tour you set up the training camp and give each specialist their own net — one lane for openers, one for the spinner, one for the death bowler — so each rehearses a focused, self-contained skill before you bring them together for a full practice match. Just as you scaffold a React project with Vite and a components folder holding Button, Input, Card, Toggle, and Modal as separate files, a camp organises separate stations for each role, each drilling one prop-driven job. Just as each component stays focused and driven by the props passed in, each net drill runs to a clear brief — 'defend the yorker', 'rotate strike' — nothing overloaded. And just as you finally compose them in App to see them work together, the camp ends with a full simulation where every rehearsed piece slots into one XI. The payoff: assembling a working whole is easy because every reusable part was built and tested in isolation first.

The plan is: profile to confirm the problem, isolate the input and memoise the rows to stop the cascade, code-split a heavy detail panel so it does not bloat the initial bundle, then write tests verifying search, selection, and the lazy panel all still work. Each step pairs an optimisation with the means to verify it.

bash
// SearchableList.jsx — the slow starting point (rows re-render on every keystroke)
import { useState, lazy, Suspense } from "react";
const DetailPanel = lazy(() => import("./DetailPanel"));   // heavy; code-split

function Row({ player, onSelect }) {                        // (will be memoised)
  return <li><button onClick={() => onSelect(player)}>{player.name}</button></li>;
}

export function SearchableList({ players }) {
  const [query, setQuery] = useState("");
  const [selected, setSelected] = useState(null);
  const visible = players.filter(p => p.name.toLowerCase().includes(query.toLowerCase()));
  return (
    <div>
      <input aria-label="Search players" value={query}
             onChange={(e) => setQuery(e.target.value)} />
      <ul>{visible.map(p => <Row key={p.id} player={p} onSelect={setSelected} />)}</ul>
      {selected && (
        <Suspense fallback={<p>Loading details</p>}>
          <DetailPanel player={selected} />
        </Suspense>
      )}
    </div>
  );
}

Step 1 — Profile and Memoise to Cut Re-renders

Use the React DevTools Profiler to record typing in the search box and confirm that every Row re-renders on each keystroke, even unchanged ones. Then apply targeted fixes: wrap Row in React.memo so unchanged rows skip re-rendering, memoise the filtered list with useMemo so it recomputes only when query or players change, and stabilise the onSelect handler with useCallback so memo's prop comparison holds.

Re-profile to verify the wasted Row renders are gone — only the input and the genuinely changed parts re-render now. This is the measure-optimise-verify loop in action: you confirmed the problem with data, applied precise memoisation, and confirmed the improvement, rather than guessing.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as a coach films a session, identifies that a batter needlessly resets their stance every ball, corrects just that, and re-films to confirm the wasted motion is gone, you profile, identify needless re-renders, memoise to fix them, and re-profile to confirm. The insight is that the before-and-after measurement validates the fix: the footage proves the wasted motion stopped, exactly as the Profiler proves the wasted renders stopped.
jsx
import { memo, useMemo, useCallback, useState } from "react";

const Row = memo(function Row({ player, onSelect }) {       // skip unchanged rows
  return <li><button onClick={() => onSelect(player)}>{player.name}</button></li>;
});

export function SearchableList({ players }) {
  const [query, setQuery] = useState("");
  const [selected, setSelected] = useState(null);
  const visible = useMemo(                                  // recompute only when needed
    () => players.filter(p => p.name.toLowerCase().includes(query.toLowerCase())),
    [players, query]
  );
  const handleSelect = useCallback((p) => setSelected(p), []);   // stable for memo'd Row
  // ... render input + visible.map(p => <Row ... onSelect={handleSelect} />)
}

Step 2 — Confirm Code Splitting Works

The DetailPanel is already lazy-loaded; verify it is genuinely split into its own chunk by checking the network tab (the chunk loads only when a player is first selected) or the bundler's output. Confirm the Suspense fallback shows briefly while the chunk loads, then the panel appears — the initial bundle no longer carries the heavy panel's code.

This demonstrates route/feature-level code splitting reducing initial load: code the user may never need (the detail panel) is deferred until actually required. Note how Suspense provides the loading state declaratively, tying back to Module 3, and how this complements the re-render optimisation from Step 1 — two different performance levers.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as specialist equipment for a rare situation is kept in reserve and brought out only if that situation arises, rather than carried onto the field every match, the detail panel's code is kept in a separate chunk and loaded only when a player is selected. The insight is that deferring what is only sometimes needed lightens the default load: the specialist gear waits in reserve, exactly as the lazy chunk waits until the feature is actually used.
jsx
// Verify the split: DetailPanel loads as a separate chunk on first selection
// (DevTools Network tab shows the chunk fetched only when 'selected' becomes truthy)

// DetailPanel.jsx — its code is NOT in the initial bundle
export default function DetailPanel({ player }) {
  // imagine a heavy chart/library here justifying the split
  return <aside><h2>{player.name}</h2><p>Detailed stats</p></aside>;
}
// In SearchableList: {selected && (
//   <Suspense fallback={<p>Loading details…</p>}><DetailPanel player={selected} /></Suspense>
// )}

Step 3 — Test the Behaviour with Vitest + RTL

Write behaviour-focused tests that prove the optimised component still works: rendering shows the full list, typing in the search filters it, and clicking a row selects it and loads the detail panel. Query by accessible role and label, interact with userEvent, and assert on visible output — never on internal state — so the tests survive the memoisation refactor.

Use findBy to await the lazily-loaded detail panel appearing after a selection, and assert the filtered results after typing. These tests confirm your optimisations preserved behaviour, completing the measure-optimise-verify loop with an automated safety net you can run on every future change.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as a coach confirms a refined technique by having the player perform it in realistic match situations and observing the outcomes, you confirm the optimised component by exercising it through realistic interactions and observing the rendered results. The insight is that realistic testing validates that the change preserved real performance: the match situations prove the technique holds, exactly as the RTL interactions prove the component's behaviour holds after optimisation.
jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { SearchableList } from "./SearchableList";

const players = [{ id: 1, name: "Kohli" }, { id: 2, name: "Rohit" }];

describe("SearchableList", () => {
  it("filters the list as the user types", async () => {
    render(<SearchableList players={players} />);
    expect(screen.getByText("Kohli")).toBeInTheDocument();
    await userEvent.type(screen.getByLabelText(/search players/i), "Roh");
    expect(screen.queryByText("Kohli")).not.toBeInTheDocument();   // filtered out
    expect(screen.getByText("Rohit")).toBeInTheDocument();
  });

  it("loads the lazy detail panel when a player is selected", async () => {
    render(<SearchableList players={players} />);
    await userEvent.click(screen.getByRole("button", { name: "Kohli" }));
    expect(await screen.findByText(/detailed stats/i)).toBeInTheDocument();  // awaits chunk
  });
});

Step 4 — Testing & Verification

Confirm the complete loop: the Profiler shows rows no longer re-render on each keystroke, the detail panel loads as a separate chunk only on first selection, and the Vitest/RTL tests pass — proving the optimisations preserved behaviour. Run the tests in watch mode and make a small change to see them guard against regressions, then re-profile to confirm performance held.

Analogy🏏Cricket
🏏 Think of it like cricket: the final selection trial where you field the full XI and check every player does their exact job under match conditions. Just as you compose all five components in App and verify each behaviour, a captain runs a scenario and confirms each role fires correctly: the buttons render variants and fire onClick like bowlers delivering their set variations on cue; the controlled Input updating parent state on every keystroke is the batter feeding the scorer every single run in real time; the Toggle flipping and reporting its state is the third-umpire light switching out and back and signalling the result. Just as the Modal opens, closes on the backdrop or button but not when its own content is clicked, a DRS review triggers on a genuine appeal, resolves cleanly, but isn't set off by incidental noise near the stumps. And just as stateless components re-render purely from props while stateful ones manage their own, pure specialists execute exactly the brief while others track their own tally. The payoff: verified, trustworthy behaviour before the real match.
jsx
// Run the suite (and watch mode during development)
//   npx vitest            # run once
//   npx vitest --watch    # re-run on change for instant feedback

// Final verification checklist:
//  - Profiler: typing re-renders only the input/changed parts, not every Row
//  - Network: DetailPanel chunk fetched only on first selection
//  - Tests: filtering + lazy-panel behaviour pass (behaviour preserved)
//  - Bundle analyzer: initial bundle no longer includes DetailPanel's code

Warning: Do not let the memoisation optimisations change behaviour silently — a stale useCallback/useMemo dependency array can freeze values and introduce subtle bugs the Profiler will not catch. This is exactly why the tests matter: they verify behaviour is preserved after optimisation. Always pair performance refactors with behaviour tests, and keep dependency arrays complete and honest so memoisation never serves stale data.

Extension Challenge: Add windowing/virtualisation (e.g. with a library like TanStack Virtual) so only visible rows render, and profile the difference against memoisation alone for very large lists. Then add a test that asserts a memoised Row does not re-render unnecessarily (using a render-count spy), measure the initial bundle before and after the code split with the bundler analyzer, and add a test for the empty-results state.

  • Profile first (React DevTools Profiler) to confirm wasted re-renders before optimising.
  • React.memo on rows + useMemo on the filtered list + useCallback on the handler stops keystroke-driven re-render cascades.
  • Code splitting (React.lazy + Suspense) defers a heavy panel's code until it's actually needed, shrinking the initial bundle.
  • Test behaviour with Vitest + RTL: query by role/label, interact with userEvent, assert on visible output (not internals).
  • Use findBy to await lazily-loaded content; tests prove optimisations preserved behaviour.
  • Pair every performance refactor with behaviour tests, since stale memo dependencies can introduce silent bugs.
Lesson 25 of 35
0% complete