React · lesson 16 of 22
Beginner
5Intermediate
12- 6 Effects and useEffect
- 7 You Might Not Need an Effect
- 8 The Rules of Hooks
- 9 useReducer and useContext
- 10 Custom Hooks
- 11 Measuring Re-renders: memo, useMemo, and useCallback
- 13 Routing with React Router
- 14 Portals with createPortal
- 15 Error Boundaries
- 16 Suspense and lazy
- 17 Reading Promises with use, No Framework Needed
- 18 Retrying After an Error with react-error-boundary
Advanced
5Also part of React
5Suspense and lazy
Split a component out with React.lazy and watch Suspense show a fallback until the real import() resolves, then spike two things the docs gloss over: nested boundary reveal order, and what a key on Suspense actually resets.
By SkillByExample · Updated
React.lazy(() => import("./Chart")) splits Chart into its own chunk and defers loading it until something actually tries to render it. Wrap the lazy component in <Suspense fallback={...}> and React shows the fallback for however long that import takes, then swaps in the real component once it resolves. The mechanics are simple; what’s easy to get wrong is what happens once boundaries start nesting, or once a key enters the picture — both worked through below with real output, since neither matched the first guess.
Project Setup
The same kind of Vitest project as Error Boundaries:
{
"name": "react-app",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"vitest": "5.0.5",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.8",
"@testing-library/jest-dom": "7.0.1",
"@vitejs/plugin-react": "6.1.2",
"jsdom": "30.1.3"
}
}
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.js"],
},
});
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
afterEach(() => {
cleanup();
});
Every command below runs inside Docker’s node:24.18.1 image, from /root/react-app, with NO_COLOR=1, and grep strips Vitest’s own Start at/Duration lines, since their numbers change on every run:
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-suspense-and-lazy-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npm install --no-audit --no-fund
added 98 packages in 27s
React.lazy Only Calls import() Once Something Tries to Render It
Chart is a perfectly ordinary component — the only thing that makes it “lazy” is how App imports it:
// src/suspense-and-lazy/Chart.tsx
export default function Chart() {
return <p>Chart loaded</p>;
}
// src/suspense-and-lazy/App.tsx
import { lazy, Suspense } from "react";
const Chart = lazy(() => {
console.log("import() called");
return import("./Chart.js");
});
export function App() {
return (
<Suspense fallback={<p>Loading chart...</p>}>
<Chart />
</Suspense>
);
}
The console.log sits inside the function passed to lazy(), not at the top of Chart.tsx — that’s deliberate. If the import were eager, the log would print the moment App.tsx itself loads, before any test even calls render:
// src/suspense-and-lazy/App.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { App } from "./App.js";
describe("React.lazy with Suspense", () => {
it("shows the fallback first, then the lazily-loaded component", async () => {
render(<App />);
console.log("first paint:", screen.getByText("Loading chart...").textContent);
const chart = await screen.findByText("Chart loaded");
console.log("after import() resolves:", chart.textContent);
expect(chart).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-suspense-and-lazy-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/suspense-and-lazy/App.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/suspense-and-lazy/App.test.tsx > React.lazy with Suspense > shows the fallback first, then the lazily-loaded component
import() called
first paint: Loading chart...
stdout | src/suspense-and-lazy/App.test.tsx > React.lazy with Suspense > shows the fallback first, then the lazily-loaded component
after import() resolves: Chart loaded
✓ src/suspense-and-lazy/App.test.tsx > React.lazy with Suspense > shows the fallback first, then the lazily-loaded component 346ms
Test Files 1 passed (1)
Tests 1 passed (1)
import() called prints only once render actually tries to show Chart — not before. The first paint is the fallback text, Loading chart..., read straight from the DOM before any await. Only after findByText resolves does the real Chart loaded text exist for it to find. Nothing here forces the import to take any real time; in this test it resolves on the next microtask, same as any other resolved promise, and findByText’s own polling is what bridges that gap.
Nested Suspense Boundaries Reveal Independently
Nest one <Suspense> inside another and each boundary only waits for its own children — not for whatever the outer boundary is waiting for. To make the reveal order observable on purpose, rather than racing against real network timing, Nav and Chart are each wrapped in a promise this example resolves by hand — resolveNav() and resolveChart() stand in for whenever a real import would actually finish:
// src/suspense-and-lazy/NestedApp.tsx
import { lazy, Suspense } from "react";
export let resolveNav: () => void = () => {};
export let resolveChart: () => void = () => {};
const Nav = lazy(() => {
console.log("Nav import() called");
return new Promise<{ default: () => React.ReactElement }>((resolve) => {
resolveNav = () => resolve({ default: () => <p>Nav loaded</p> });
});
});
const Chart = lazy(() => {
console.log("Chart import() called");
return new Promise<{ default: () => React.ReactElement }>((resolve) => {
resolveChart = () => resolve({ default: () => <p>Chart loaded</p> });
});
});
export function NestedApp() {
return (
<Suspense fallback={<p>Loading page...</p>}>
<Nav />
<Suspense fallback={<p>Loading chart...</p>}>
<Chart />
</Suspense>
</Suspense>
);
}
The outer boundary only has Nav as a direct child — the nested boundary absorbs Chart’s suspension entirely, so the outer one never has to wait for it. The test resolves Nav first, then Chart, and checks the DOM after each one:
// src/suspense-and-lazy/NestedApp.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { NestedApp, resolveNav, resolveChart } from "./NestedApp.js";
describe("nested suspense reveal order", () => {
it("reveals the outer boundary, then the inner one, independently", async () => {
let container: HTMLElement;
await act(async () => {
({ container } = render(<NestedApp />));
});
console.log("before anything resolves:", container.textContent);
await act(async () => {
resolveNav();
});
await screen.findByText("Nav loaded");
console.log("after resolving Nav:", container.textContent);
await act(async () => {
resolveChart();
});
await screen.findByText("Chart loaded");
console.log("after resolving Chart:", container.textContent);
expect(container.textContent).toBe("Nav loadedChart loaded");
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-suspense-and-lazy-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/suspense-and-lazy/NestedApp.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/suspense-and-lazy/NestedApp.test.tsx > nested suspense reveal order > reveals the outer boundary, then the inner one, independently
Nav import() called
Chart import() called
stdout | src/suspense-and-lazy/NestedApp.test.tsx > nested suspense reveal order > reveals the outer boundary, then the inner one, independently
before anything resolves: Loading page...
stdout | src/suspense-and-lazy/NestedApp.test.tsx > nested suspense reveal order > reveals the outer boundary, then the inner one, independently
after resolving Nav: Nav loadedLoading chart...
stdout | src/suspense-and-lazy/NestedApp.test.tsx > nested suspense reveal order > reveals the outer boundary, then the inner one, independently
after resolving Chart: Nav loadedChart loaded
✓ src/suspense-and-lazy/NestedApp.test.tsx > nested suspense reveal order > reveals the outer boundary, then the inner one, independently 54ms
Test Files 1 passed (1)
Tests 1 passed (1)
Both Nav import() called and Chart import() called print before the test ever reads container.textContent — but that’s not because React renders the whole tree in one synchronous pass. Nav is the outer boundary’s own direct child, so React hits its suspension first and commits the outer fallback, Loading page..., right away. Only after that commit does React come back and pre-render what’s inside that boundary in a separate pass — which is when the nested boundary gets a chance to render its own child, Chart, calling Chart’s import() too, even though nothing has resolved yet. That second pass is also why Chart’s import() is called before Nav even resolves: by the time resolveNav() runs, Chart has already suspended once inside its own boundary — though the nested boundary would show Loading chart... on that first render regardless, pre-warmed import or not. Only once resolveChart() runs does the nested boundary swap in Chart loaded. Each boundary still reveals the moment its own children are ready — nesting is what lets the shell of a page appear before every last piece of it has loaded; it’s just that the content nested inside a fallback gets pre-rendered in a follow-up pass after that fallback commits, not inside the same pass as the outer render.
A key Remounts the Boundary — But Doesn’t Replay an Already-Resolved Import
A common suggestion for resetting a Suspense boundary is to give it a key: changing the key unmounts the old subtree and mounts a fresh one, the same way a key works on any other element. What that does not do, at least for a React.lazy component whose import has already resolved, is make the fallback reappear:
// src/suspense-and-lazy/KeyedChart.tsx
import { useEffect } from "react";
export default function Chart() {
useEffect(() => {
console.log("Chart mounted");
return () => console.log("Chart unmounted");
}, []);
return <p data-testid="chart-slot">Chart loaded</p>;
}
// src/suspense-and-lazy/KeyedApp.tsx
import { lazy, Suspense, useState } from "react";
export let resolveChart: () => void = () => {};
const Chart = lazy(() => {
console.log("import() called");
return new Promise((resolve) => {
resolveChart = () => resolve(import("./KeyedChart.js"));
});
});
export function KeyedApp() {
const [chartKey, setChartKey] = useState("a");
return (
<div>
<button onClick={() => setChartKey((k) => (k === "a" ? "b" : "a"))}>
Remount
</button>
<Suspense key={chartKey} fallback={<p data-testid="chart-slot">Loading chart...</p>}>
<Chart />
</Suspense>
</div>
);
}
resolveChart is exported the same way as the nested example above, so the test controls exactly when the real import("./KeyedChart.js") settles. Clicking the button flips chartKey between "a" and "b", which remounts the Suspense boundary and everything inside it:
// src/suspense-and-lazy/KeyedApp.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { KeyedApp, resolveChart } from "./KeyedApp.js";
describe("a key prop on a Suspense boundary", () => {
it("remounts the subtree without replaying an already-resolved import", async () => {
const user = userEvent.setup({ delay: null });
await act(async () => {
render(<KeyedApp />);
});
console.log("before load:", screen.getByTestId("chart-slot").textContent);
await act(async () => {
resolveChart();
});
await screen.findByText("Chart loaded");
console.log("after load:", screen.getByTestId("chart-slot").textContent);
await user.click(screen.getByRole("button", { name: "Remount" }));
console.log("right after remounting:", screen.getByTestId("chart-slot").textContent);
expect(screen.getByTestId("chart-slot")).toHaveTextContent("Chart loaded");
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-suspense-and-lazy-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/suspense-and-lazy/KeyedApp.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/suspense-and-lazy/KeyedApp.test.tsx > a key prop on a Suspense boundary > remounts the subtree without replaying an already-resolved import
import() called
stdout | src/suspense-and-lazy/KeyedApp.test.tsx > a key prop on a Suspense boundary > remounts the subtree without replaying an already-resolved import
before load: Loading chart...
stdout | src/suspense-and-lazy/KeyedApp.test.tsx > a key prop on a Suspense boundary > remounts the subtree without replaying an already-resolved import
Chart mounted
stdout | src/suspense-and-lazy/KeyedApp.test.tsx > a key prop on a Suspense boundary > remounts the subtree without replaying an already-resolved import
after load: Chart loaded
stdout | src/suspense-and-lazy/KeyedApp.test.tsx > a key prop on a Suspense boundary > remounts the subtree without replaying an already-resolved import
Chart unmounted
Chart mounted
stdout | src/suspense-and-lazy/KeyedApp.test.tsx > a key prop on a Suspense boundary > remounts the subtree without replaying an already-resolved import
right after remounting: Chart loaded
stdout | src/suspense-and-lazy/KeyedApp.test.tsx > a key prop on a Suspense boundary > remounts the subtree without replaying an already-resolved import
Chart unmounted
✓ src/suspense-and-lazy/KeyedApp.test.tsx > a key prop on a Suspense boundary > remounts the subtree without replaying an already-resolved import 492ms
Test Files 1 passed (1)
Tests 1 passed (1)
import() called prints exactly once in the whole test. After the key change, Chart unmounted and Chart mounted both fire — proof the remount is real, not a no-op — but right after remounting already reads Chart loaded, with no Loading chart... in between. React.lazy caches a resolved import on the lazy() call itself, which lives outside the component and survives the remount; by the time the new Suspense boundary renders Chart again, the cached result is already there to read synchronously, so nothing suspends and the fallback has nothing to show. A key resets component state and re-runs effects — it does not make an already-settled lazy import happen again.
Practice
1. Resolve NestedApp’s Chart before Nav and confirm the outer boundary still gates everything, even the already-resolved nested one.
Solution
// src/suspense-and-lazy/practice/SwappedApp.tsx
import { lazy, Suspense } from "react";
export let resolveNav: () => void = () => {};
export let resolveChart: () => void = () => {};
const Nav = lazy(() => {
console.log("Nav import() called");
return new Promise<{ default: () => React.ReactElement }>((resolve) => {
resolveNav = () => resolve({ default: () => <p>Nav loaded</p> });
});
});
const Chart = lazy(() => {
console.log("Chart import() called");
return new Promise<{ default: () => React.ReactElement }>((resolve) => {
resolveChart = () => resolve({ default: () => <p>Chart loaded</p> });
});
});
export function SwappedApp() {
return (
<Suspense fallback={<p>Loading page...</p>}>
<Nav />
<Suspense fallback={<p>Loading chart...</p>}>
<Chart />
</Suspense>
</Suspense>
);
}
// src/suspense-and-lazy/practice/SwappedApp.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { SwappedApp, resolveNav, resolveChart } from "./SwappedApp.js";
describe("nested suspense, inner resolves before outer", () => {
it("still gates the already-resolved inner content behind the outer boundary", async () => {
let container: HTMLElement;
await act(async () => {
({ container } = render(<SwappedApp />));
});
console.log("before anything resolves:", container.textContent);
await act(async () => {
resolveChart();
});
console.log("after resolving Chart only (nav still pending):", container.textContent);
await act(async () => {
resolveNav();
});
await screen.findByText("Nav loaded");
console.log("after resolving Nav too:", container.textContent);
expect(container.textContent).toBe("Nav loadedChart loaded");
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-suspense-and-lazy-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/suspense-and-lazy/practice/SwappedApp.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/suspense-and-lazy/practice/SwappedApp.test.tsx > nested suspense, inner resolves before outer > still gates the already-resolved inner content behind the outer boundary
Nav import() called
Chart import() called
stdout | src/suspense-and-lazy/practice/SwappedApp.test.tsx > nested suspense, inner resolves before outer > still gates the already-resolved inner content behind the outer boundary
before anything resolves: Loading page...
stdout | src/suspense-and-lazy/practice/SwappedApp.test.tsx > nested suspense, inner resolves before outer > still gates the already-resolved inner content behind the outer boundary
after resolving Chart only (nav still pending): Loading page...
stdout | src/suspense-and-lazy/practice/SwappedApp.test.tsx > nested suspense, inner resolves before outer > still gates the already-resolved inner content behind the outer boundary
after resolving Nav too: Nav loadedChart loaded
✓ src/suspense-and-lazy/practice/SwappedApp.test.tsx > nested suspense, inner resolves before outer > still gates the already-resolved inner content behind the outer boundary 50ms
Test Files 1 passed (1)
Tests 1 passed (1)
Resolving Chart first does nothing visible — the outer boundary is still showing Loading page..., because Nav, its own direct child, hasn’t resolved yet. The nested boundary’s readiness never gets a chance to show on its own; it’s stuck behind whatever the outer boundary is waiting for. Only once Nav also resolves does the whole thing reveal — and Chart loaded appears at the same instant, because it had been ready the whole time.
2. Remove the nested <Suspense> from NestedApp — make Chart a direct sibling of Nav under one shared boundary — and confirm neither one can show until both have resolved.
Solution
// src/suspense-and-lazy/practice/FlatApp.tsx
import { lazy, Suspense } from "react";
export let resolveNav: () => void = () => {};
export let resolveChart: () => void = () => {};
const Nav = lazy(() => {
console.log("Nav import() called");
return new Promise<{ default: () => React.ReactElement }>((resolve) => {
resolveNav = () => resolve({ default: () => <p>Nav loaded</p> });
});
});
const Chart = lazy(() => {
console.log("Chart import() called");
return new Promise<{ default: () => React.ReactElement }>((resolve) => {
resolveChart = () => resolve({ default: () => <p>Chart loaded</p> });
});
});
export function FlatApp() {
return (
<Suspense fallback={<p>Loading page...</p>}>
<Nav />
<Chart />
</Suspense>
);
}
// src/suspense-and-lazy/practice/FlatApp.test.tsx
import { describe, it, expect } from "vitest";
import { render, act } from "@testing-library/react";
import { FlatApp, resolveNav, resolveChart } from "./FlatApp.js";
describe("one shared boundary, no nesting", () => {
it("waits for every suspended child before revealing anything", async () => {
let container: HTMLElement;
await act(async () => {
({ container } = render(<FlatApp />));
});
console.log("before anything resolves:", container.textContent);
await act(async () => {
resolveNav();
});
console.log("after resolving Nav only (chart still pending):", container.textContent);
await act(async () => {
resolveChart();
});
console.log("after resolving Chart too:", container.textContent);
expect(container.textContent).toBe("Nav loadedChart loaded");
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-suspense-and-lazy-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/suspense-and-lazy/practice/FlatApp.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/suspense-and-lazy/practice/FlatApp.test.tsx > one shared boundary, no nesting > waits for every suspended child before revealing anything
Nav import() called
Chart import() called
stdout | src/suspense-and-lazy/practice/FlatApp.test.tsx > one shared boundary, no nesting > waits for every suspended child before revealing anything
before anything resolves: Loading page...
stdout | src/suspense-and-lazy/practice/FlatApp.test.tsx > one shared boundary, no nesting > waits for every suspended child before revealing anything
after resolving Nav only (chart still pending): Loading page...
stdout | src/suspense-and-lazy/practice/FlatApp.test.tsx > one shared boundary, no nesting > waits for every suspended child before revealing anything
after resolving Chart too: Nav loadedChart loaded
✓ src/suspense-and-lazy/practice/FlatApp.test.tsx > one shared boundary, no nesting > waits for every suspended child before revealing anything 32ms
Test Files 1 passed (1)
Tests 1 passed (1)
Resolving Nav alone changes nothing — the page still shows Loading page... — because with no nested boundary to absorb Chart’s suspension, the one shared boundary has to wait for both. Compare this to Practice 1 and the lesson’s own nested example: nesting doesn’t change when each import resolves, only which boundary is forced to wait for which children.
3. Give KeyedApp an unrelated piece of state (an unrelated counter button) that re-renders the component without changing chartKey. Confirm the chart’s effect does not re-fire — only an actual key change remounts it.
Solution
// src/suspense-and-lazy/practice/UnrelatedApp.tsx
import { lazy, Suspense, useState, useEffect } from "react";
export let resolveChart: () => void = () => {};
const Chart = lazy(() => {
console.log("import() called");
return new Promise<{ default: () => React.ReactElement }>((resolve) => {
resolveChart = () => resolve({ default: ChartImpl });
});
});
function ChartImpl() {
useEffect(() => {
console.log("Chart mounted");
return () => console.log("Chart unmounted");
}, []);
return <p data-testid="chart-slot">Chart loaded</p>;
}
export function UnrelatedApp() {
const [count, setCount] = useState(0);
const [chartKey] = useState("a");
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>
<Suspense key={chartKey} fallback={<p data-testid="chart-slot">Loading chart...</p>}>
<Chart />
</Suspense>
</div>
);
}
// src/suspense-and-lazy/practice/UnrelatedApp.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UnrelatedApp, resolveChart } from "./UnrelatedApp.js";
describe("an unrelated state update next to a stable key", () => {
it("does not remount or reload the lazy chart", async () => {
const user = userEvent.setup({ delay: null });
await act(async () => {
render(<UnrelatedApp />);
});
await act(async () => {
resolveChart();
});
await screen.findByText("Chart loaded");
console.log("after load:", screen.getByTestId("chart-slot").textContent);
await user.click(screen.getByRole("button"));
console.log(
"after unrelated click:",
screen.getByTestId("chart-slot").textContent,
"|",
screen.getByRole("button").textContent,
);
expect(screen.getByRole("button")).toHaveTextContent("Count: 1");
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-suspense-and-lazy-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/suspense-and-lazy/practice/UnrelatedApp.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/suspense-and-lazy/practice/UnrelatedApp.test.tsx > an unrelated state update next to a stable key > does not remount or reload the lazy chart
import() called
stdout | src/suspense-and-lazy/practice/UnrelatedApp.test.tsx > an unrelated state update next to a stable key > does not remount or reload the lazy chart
Chart mounted
stdout | src/suspense-and-lazy/practice/UnrelatedApp.test.tsx > an unrelated state update next to a stable key > does not remount or reload the lazy chart
after load: Chart loaded
stdout | src/suspense-and-lazy/practice/UnrelatedApp.test.tsx > an unrelated state update next to a stable key > does not remount or reload the lazy chart
after unrelated click: Chart loaded | Count: 1
stdout | src/suspense-and-lazy/practice/UnrelatedApp.test.tsx > an unrelated state update next to a stable key > does not remount or reload the lazy chart
Chart unmounted
✓ src/suspense-and-lazy/practice/UnrelatedApp.test.tsx > an unrelated state update next to a stable key > does not remount or reload the lazy chart 219ms
Test Files 1 passed (1)
Tests 1 passed (1)
Chart mounted prints once, during the initial load, and does not print again after the unrelated click — only Count: 1 changes. chartKey never changed, so React treats this as the same Suspense boundary and the same Chart instance across the re-render; the chart’s own effect has no reason to clean up and re-run. Contrast this with the lesson’s own key example, where the key itself changed and both Chart unmounted and Chart mounted fired.
4. Instead of remounting the same lazy Chart, switch KeyedApp’s key between two genuinely different lazy components (BarChart and LineChart) that have never both finished loading. Confirm each one suspends the first time it’s shown.
Solution
// src/suspense-and-lazy/practice/TwoChartsApp.tsx
import { lazy, Suspense, useState } from "react";
export let resolveBar: () => void = () => {};
export let resolveLine: () => void = () => {};
const BarChart = lazy(() => {
console.log("BarChart import() called");
return new Promise<{ default: () => React.ReactElement }>((resolve) => {
resolveBar = () => resolve({ default: () => <p data-testid="chart-slot">Bar chart</p> });
});
});
const LineChart = lazy(() => {
console.log("LineChart import() called");
return new Promise<{ default: () => React.ReactElement }>((resolve) => {
resolveLine = () => resolve({ default: () => <p data-testid="chart-slot">Line chart</p> });
});
});
export function TwoChartsApp() {
const [kind, setKind] = useState("bar");
const ChartForKind = kind === "bar" ? BarChart : LineChart;
return (
<div>
<button onClick={() => setKind((k) => (k === "bar" ? "line" : "bar"))}>
Switch
</button>
<Suspense key={kind} fallback={<p data-testid="chart-slot">Loading chart...</p>}>
<ChartForKind />
</Suspense>
</div>
);
}
// src/suspense-and-lazy/practice/TwoChartsApp.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TwoChartsApp, resolveBar, resolveLine } from "./TwoChartsApp.js";
describe("switching to a genuinely different lazy component via key", () => {
it("suspends again, because this import has never resolved before", async () => {
const user = userEvent.setup({ delay: null });
await act(async () => {
render(<TwoChartsApp />);
});
await act(async () => {
resolveBar();
});
await screen.findByText("Bar chart");
console.log("bar chart loaded:", screen.getByTestId("chart-slot").textContent);
await user.click(screen.getByRole("button"));
console.log("right after switching to line:", screen.getByTestId("chart-slot").textContent);
await act(async () => {
resolveLine();
});
await screen.findByText("Line chart");
console.log("after line chart loads:", screen.getByTestId("chart-slot").textContent);
expect(screen.getByTestId("chart-slot")).toHaveTextContent("Line chart");
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-suspense-and-lazy-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/suspense-and-lazy/practice/TwoChartsApp.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/suspense-and-lazy/practice/TwoChartsApp.test.tsx > switching to a genuinely different lazy component via key > suspends again, because this import has never resolved before
BarChart import() called
stdout | src/suspense-and-lazy/practice/TwoChartsApp.test.tsx > switching to a genuinely different lazy component via key > suspends again, because this import has never resolved before
bar chart loaded: Bar chart
stdout | src/suspense-and-lazy/practice/TwoChartsApp.test.tsx > switching to a genuinely different lazy component via key > suspends again, because this import has never resolved before
LineChart import() called
stdout | src/suspense-and-lazy/practice/TwoChartsApp.test.tsx > switching to a genuinely different lazy component via key > suspends again, because this import has never resolved before
right after switching to line: Loading chart...
stdout | src/suspense-and-lazy/practice/TwoChartsApp.test.tsx > switching to a genuinely different lazy component via key > suspends again, because this import has never resolved before
after line chart loads: Line chart
✓ src/suspense-and-lazy/practice/TwoChartsApp.test.tsx > switching to a genuinely different lazy component via key > suspends again, because this import has never resolved before 390ms
Test Files 1 passed (1)
Tests 1 passed (1)
Right after switching, the slot shows Loading chart... again — unlike the lesson’s own example, this really is a fresh suspension. LineChart import() called only prints at that point, not before: BarChart and LineChart are two separate lazy() calls, each with its own cache, so loading one has no effect on the other. The lesson’s key example and this one aren’t contradictory — a key change only skips the fallback when the specific lazy component behind that key has already resolved before.