Skip to the code
cf.

Tea details with use and Suspense

The tea shop's menu shows one tea's details at a time. Build TeaPicker, the default export, which loads the details with use() and lets Suspense show the waiting state, instead of an Effect and a loading flag.

It takes fetchTea(id), which returns a promise of { name, origin, notes }. The default in the starter is a pretend server that answers after a short wait. In this problem the promise never rejects.

Render three buttons, Sencha, Assam, and Rooibos (ids sencha, assam, rooibos), in a group labelled Teas. The chosen one has aria-pressed="true", the others aria-pressed="false". Sencha is chosen at first.

Below the buttons, show the chosen tea: its name in a heading and From followed by its origin in a paragraph, then its notes. While that tea is loading, show Loading tea… (with the single … character) in its place. The buttons stay on screen the whole time.

Call fetchTea once per tea, ever: keep each tea's promise in a cache that lives as long as the component, and read it with use(). Going back to a tea that's already loaded shows it at once, with no Loading tea…. A tea that answers after you moved on to another must never be shown.

<TeaPicker fetchTea={fetchTea} />
 
// [Sencha] [Assam] [Rooibos]
// Loading tea…
// ...then:
// Sencha
// From Japan
// Grassy and bright.

What the tests check

  1. shows Loading tea… until the first tea arrives
    // settle(work): render or click inside an awaited act(), so a component that
    // suspends on use() can show its content once the promise resolves.
    const loads = {};
    const fetchTea = spy((id) => (loads[id] = deferred()).promise);
    await settle(() => render(<App fetchTea={fetchTea} />));
    expect(fetchTea).toHaveBeenCalledWith("sencha");
    expect(screen.getByText("Loading tea…")).toBeInTheDocument();
    loads.sencha.resolve({ name: "Sencha", origin: "Japan", notes: "Grassy and bright." });
    expect(await screen.findByRole("heading", { name: "Sencha" })).toBeInTheDocument();
    expect(screen.getByText("From Japan")).toBeInTheDocument();
    expect(screen.getByText("Grassy and bright.")).toBeInTheDocument();
    expect(screen.queryByText("Loading tea…")).not.toBeInTheDocument();
    
  2. fetches the first tea only once
    // settle(work): render or click inside an awaited act(), so a component that
    // suspends on use() can show its content once the promise resolves.
    const fetchTea = spy(async () => ({ name: "Sencha", origin: "Japan", notes: "Grassy and bright." }));
    await settle(() => render(<App fetchTea={fetchTea} />));
    expect(await screen.findByRole("heading", { name: "Sencha" })).toBeInTheDocument();
    await flush();
    expect(fetchTea).toHaveBeenCalledTimes(1);
    
  3. keeps the buttons on screen while a tea loads
    // settle(work): render or click inside an awaited act(), so a component that
    // suspends on use() can show its content once the promise resolves.
    await settle(() => render(<App fetchTea={() => new Promise(() => {})} />));
    const group = screen.getByRole("group", { name: "Teas" });
    expect(within(group).getAllByRole("button").map((button) => button.textContent)).toEqual(["Sencha", "Assam", "Rooibos"]);
    expect(screen.getByText("Loading tea…")).toBeInTheDocument();
    
  4. marks the chosen tea with aria-pressed
    // settle(work): render or click inside an awaited act(), so a component that
    // suspends on use() can show its content once the promise resolves.
    await settle(() => render(<App fetchTea={() => new Promise(() => {})} />));
    expect(screen.getByRole("button", { name: "Sencha" })).toHaveAttribute("aria-pressed", "true");
    expect(screen.getByRole("button", { name: "Assam" })).toHaveAttribute("aria-pressed", "false");
    await settle(() => fireEvent.click(screen.getByRole("button", { name: "Assam" })));
    expect(screen.getByRole("button", { name: "Assam" })).toHaveAttribute("aria-pressed", "true");
    expect(screen.getByRole("button", { name: "Sencha" })).toHaveAttribute("aria-pressed", "false");
    
  5. loads another tea when its button is pressed
    // settle(work): render or click inside an awaited act(), so a component that
    // suspends on use() can show its content once the promise resolves.
    const loads = {};
    const fetchTea = spy((id) => (loads[id] ??= deferred()).promise);
    await settle(() => render(<App fetchTea={fetchTea} />));
    loads.sencha.resolve({ name: "Sencha", origin: "Japan", notes: "Grassy and bright." });
    expect(await screen.findByRole("heading", { name: "Sencha" })).toBeInTheDocument();
    await settle(() => fireEvent.click(screen.getByRole("button", { name: "Assam" })));
    expect(fetchTea).toHaveBeenCalledWith("assam");
    expect(screen.getByText("Loading tea…")).toBeInTheDocument();
    loads.assam.resolve({ name: "Assam", origin: "India", notes: "Malty and strong." });
    expect(await screen.findByRole("heading", { name: "Assam" })).toBeInTheDocument();
    expect(screen.getByText("From India")).toBeInTheDocument();
    
  6. shows a tea it already loaded at once, without fetching again
    // settle(work): render or click inside an awaited act(), so a component that
    // suspends on use() can show its content once the promise resolves.
    const loads = {};
    const fetchTea = spy((id) => (loads[id] ??= deferred()).promise);
    await settle(() => render(<App fetchTea={fetchTea} />));
    loads.sencha.resolve({ name: "Sencha", origin: "Japan", notes: "Grassy and bright." });
    expect(await screen.findByRole("heading", { name: "Sencha" })).toBeInTheDocument();
    await settle(() => fireEvent.click(screen.getByRole("button", { name: "Assam" })));
    loads.assam.resolve({ name: "Assam", origin: "India", notes: "Malty and strong." });
    expect(await screen.findByRole("heading", { name: "Assam" })).toBeInTheDocument();
    await settle(() => fireEvent.click(screen.getByRole("button", { name: "Sencha" })));
    expect(screen.getByRole("heading", { name: "Sencha" })).toBeInTheDocument();
    expect(screen.queryByText("Loading tea…")).not.toBeInTheDocument();
    expect(fetchTea.calls.map((call) => call.args[0])).toEqual(["sencha", "assam"]);
    
  7. never shows a tea that answers after you moved on
    // settle(work): render or click inside an awaited act(), so a component that
    // suspends on use() can show its content once the promise resolves.
    const loads = {};
    await settle(() => render(<App fetchTea={(id) => (loads[id] ??= deferred()).promise} />));
    await settle(() => fireEvent.click(screen.getByRole("button", { name: "Assam" })));
    await settle(() => fireEvent.click(screen.getByRole("button", { name: "Rooibos" })));
    loads.rooibos.resolve({ name: "Rooibos", origin: "South Africa", notes: "Sweet and caffeine free." });
    expect(await screen.findByRole("heading", { name: "Rooibos" })).toBeInTheDocument();
    loads.assam.resolve({ name: "Assam", origin: "India", notes: "Malty and strong." });
    loads.sencha.resolve({ name: "Sencha", origin: "Japan", notes: "Grassy and bright." });
    await flush();
    expect(screen.getByRole("heading", { name: "Rooibos" })).toBeInTheDocument();
    expect(screen.queryByRole("heading", { name: "Assam" })).not.toBeInTheDocument();
    expect(screen.queryByRole("heading", { name: "Sencha" })).not.toBeInTheDocument();
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).