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.
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.
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.
// 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.
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.
// 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.
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.
// 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 codeWarning: 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.