Skip to main content
React · lesson 16 of 22
React intermediate Lesson 16 of 22

Suspense 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 suspense lazy code-splitting vitest

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.

Frequently Asked Questions

When does React.lazy actually call the import() function I pass it?
Not at module load time, and not when the lazy component is defined — only the first time React attempts to render it. Logging inside the function passed to lazy(), rather than at the top of the imported module, is the way to see this for real: the log line only appears once a Suspense boundary actually tries to show that component.
Does a nested Suspense boundary wait for its parent to resolve first?
No, but not because it waits for the parent either. React commits the outer boundary's fallback first, as soon as its own direct child suspends, then pre-renders whatever is nested inside that boundary in a separate pass right after — which is how a lazy import nested two levels deep can call import() before the outer boundary has revealed anything real. What the nesting controls is when each result is shown: the outer boundary only needs its own direct children to resolve, and reveals as soon as they do, even if the nested boundary is still showing its own fallback.
Does giving a Suspense boundary a different key force it to reload an already-loaded lazy component?
No, in React 19.3 it does not. Changing a key remounts the boundary and everything inside it — effects clean up and re-run — but React.lazy caches the result of a resolved import on the lazy() call itself, not on the component instance. If that lazy component already finished loading once, a remount reads the cached result synchronously and never shows the fallback again.
So when does a key on Suspense actually matter?
When the key switches between two different lazy-loaded components (or, more commonly, between two different pieces of async content read with something like use()) that haven't both already resolved. Each distinct key/content pairing gets its own loading state the first time it's shown; it's only a repeat visit to an already-resolved pairing that skips the fallback.
Can I use Suspense for something other than React.lazy?
Yes — Suspense shows its fallback for any descendant that suspends, which today in stable React also includes components reading a promise with the use() hook or certain data-fetching libraries built on Suspense. This lesson only covers the React.lazy case; reading a promise directly with use() is covered in the next lesson.