Skip to the code
cf.

Cached fetch

Build TeaTabs, the default export: a small tea shop's menu with three tabs, Green, Black, and Herbal. Make them buttons with role="tab" inside an element with role="tablist", and give the open one aria-selected="true" (the others "false"). Green is open at first.

Each tab's list comes from /api/teas?type=green (or black, or herbal): a JSON array like [{ id: 1, name: "Sencha" }]. Show the open tab's teas in a list, one item each, or Loading… (with the single … character) while that tab has no list yet.

Fetch each list once and keep it. Opening a tab you've seen before shows its saved list straight away, with no request. Opening a tab whose list is still on its way doesn't ask again. An answer that arrives after you've left its tab is saved for that tab, and never shows on another one.

A Refresh button fetches the open tab's list again. While it waits, the saved list stays on screen and Refresh is disabled. Then the new list replaces the old one, on screen and in what you've saved.

If a request fails (fetch rejects, or the response isn't ok), show Couldn't load these teas. whenever that tab is open, and keep any saved list on screen. Refresh tries again; opening the tab again doesn't, and still shows the error.

<div role="tablist">
  <button role="tab" aria-selected="true">Green</button>
  <button role="tab" aria-selected="false">Black</button>
  <button role="tab" aria-selected="false">Herbal</button>
</div>
<button>Refresh</button>
<ul><li>Sencha</li><li>Matcha</li></ul>

What the tests check

  1. opens Green and fetches its list
    const menu = {
      green: [{ id: 1, name: "Sencha" }, { id: 2, name: "Matcha" }],
      black: [{ id: 3, name: "Assam" }],
      herbal: [{ id: 4, name: "Rooibos" }],
    };
    const typeIn = (url) => url.split("type=")[1];
    const fetch = mockFetch((url) => menu[typeIn(url)]);
    render(<App />);
    expect(screen.getByRole("tab", { name: "Green" })).toHaveAttribute("aria-selected", "true");
    expect(screen.getByRole("tab", { name: "Black" })).toHaveAttribute("aria-selected", "false");
    expect(screen.getByText("Loading…")).toBeInTheDocument();
    const items = await screen.findAllByRole("listitem");
    expect(items.map((item) => item.textContent)).toEqual(["Sencha", "Matcha"]);
    expect(fetch.calls.map((call) => call.url)).toEqual(["/api/teas?type=green"]);
    
  2. opening a tab selects it and fetches its list
    const menu = {
      green: [{ id: 1, name: "Sencha" }, { id: 2, name: "Matcha" }],
      black: [{ id: 3, name: "Assam" }],
      herbal: [{ id: 4, name: "Rooibos" }],
    };
    const typeIn = (url) => url.split("type=")[1];
    const fetch = mockFetch((url) => menu[typeIn(url)]);
    render(<App />);
    await screen.findByText("Sencha");
    await user.click(screen.getByRole("tab", { name: "Black" }));
    expect(screen.getByRole("tab", { name: "Black" })).toHaveAttribute("aria-selected", "true");
    expect(screen.getByRole("tab", { name: "Green" })).toHaveAttribute("aria-selected", "false");
    expect(await screen.findByText("Assam")).toBeInTheDocument();
    expect(screen.queryByText("Sencha")).not.toBeInTheDocument();
    expect(fetch.calls.map((call) => call.url)).toEqual(["/api/teas?type=green", "/api/teas?type=black"]);
    
  3. a tab you've seen before shows its list at once, with no request
    const menu = {
      green: [{ id: 1, name: "Sencha" }, { id: 2, name: "Matcha" }],
      black: [{ id: 3, name: "Assam" }],
      herbal: [{ id: 4, name: "Rooibos" }],
    };
    const typeIn = (url) => url.split("type=")[1];
    const fetch = mockFetch((url) => menu[typeIn(url)]);
    render(<App />);
    await screen.findByText("Sencha");
    await user.click(screen.getByRole("tab", { name: "Black" }));
    await screen.findByText("Assam");
    await user.click(screen.getByRole("tab", { name: "Green" }));
    expect(screen.getByText("Sencha")).toBeInTheDocument();
    expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
    await flush();
    expect(fetch.calls).toHaveLength(2);
    
  4. doesn't ask twice for a list that's still on its way
    const menu = {
      green: [{ id: 1, name: "Sencha" }, { id: 2, name: "Matcha" }],
      black: [{ id: 3, name: "Assam" }],
      herbal: [{ id: 4, name: "Rooibos" }],
    };
    const typeIn = (url) => url.split("type=")[1];
    const green = deferred();
    const fetch = mockFetch((url) => (typeIn(url) === "green" ? green.promise : menu[typeIn(url)]));
    render(<App />);
    await user.click(screen.getByRole("tab", { name: "Black" }));
    await screen.findByText("Assam");
    await user.click(screen.getByRole("tab", { name: "Green" }));
    expect(screen.getByText("Loading…")).toBeInTheDocument();
    green.resolve(menu.green);
    expect(await screen.findByText("Sencha")).toBeInTheDocument();
    expect(fetch.calls.map((call) => typeIn(call.url))).toEqual(["green", "black"]);
    
  5. a late answer is saved for its own tab and never shows on another
    const menu = {
      green: [{ id: 1, name: "Sencha" }, { id: 2, name: "Matcha" }],
      black: [{ id: 3, name: "Assam" }],
      herbal: [{ id: 4, name: "Rooibos" }],
    };
    const typeIn = (url) => url.split("type=")[1];
    const green = deferred();
    const fetch = mockFetch((url) => (typeIn(url) === "green" ? green.promise : menu[typeIn(url)]));
    render(<App />);
    await user.click(screen.getByRole("tab", { name: "Black" }));
    await screen.findByText("Assam");
    green.resolve(menu.green);
    await flush();
    expect(screen.queryByText("Sencha")).not.toBeInTheDocument();
    expect(screen.getByText("Assam")).toBeInTheDocument();
    await user.click(screen.getByRole("tab", { name: "Green" }));
    expect(screen.getByText("Sencha")).toBeInTheDocument();
    await flush();
    expect(fetch.calls).toHaveLength(2);
    
  6. Refresh fetches the open tab again and shows the new list
    const menu = {
      green: [{ id: 1, name: "Sencha" }, { id: 2, name: "Matcha" }],
      black: [{ id: 3, name: "Assam" }],
      herbal: [{ id: 4, name: "Rooibos" }],
    };
    const typeIn = (url) => url.split("type=")[1];
    let greens = 0;
    const fetch = mockFetch((url) => (++greens === 1 ? menu.green : [{ id: 5, name: "Gyokuro" }]));
    render(<App />);
    await screen.findByText("Sencha");
    await user.click(screen.getByRole("button", { name: "Refresh" }));
    expect(await screen.findByText("Gyokuro")).toBeInTheDocument();
    expect(screen.queryByText("Sencha")).not.toBeInTheDocument();
    expect(fetch.calls.map((call) => call.url)).toEqual(["/api/teas?type=green", "/api/teas?type=green"]);
    
  7. keeps the saved list on screen while Refresh waits, with Refresh disabled
    const menu = {
      green: [{ id: 1, name: "Sencha" }, { id: 2, name: "Matcha" }],
      black: [{ id: 3, name: "Assam" }],
      herbal: [{ id: 4, name: "Rooibos" }],
    };
    const typeIn = (url) => url.split("type=")[1];
    const again = deferred();
    let greens = 0;
    mockFetch(() => (++greens === 1 ? menu.green : again.promise));
    render(<App />);
    await screen.findByText("Sencha");
    await user.click(screen.getByRole("button", { name: "Refresh" }));
    expect(screen.getByText("Sencha")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled();
    again.resolve([{ id: 5, name: "Gyokuro" }]);
    expect(await screen.findByText("Gyokuro")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled();
    
  8. a refreshed list replaces the saved one
    const menu = {
      green: [{ id: 1, name: "Sencha" }, { id: 2, name: "Matcha" }],
      black: [{ id: 3, name: "Assam" }],
      herbal: [{ id: 4, name: "Rooibos" }],
    };
    const typeIn = (url) => url.split("type=")[1];
    let greens = 0;
    const fetch = mockFetch((url) =>
      typeIn(url) !== "green" ? menu[typeIn(url)] : ++greens === 1 ? menu.green : [{ id: 5, name: "Gyokuro" }],
    );
    render(<App />);
    await screen.findByText("Sencha");
    await user.click(screen.getByRole("button", { name: "Refresh" }));
    await screen.findByText("Gyokuro");
    await user.click(screen.getByRole("tab", { name: "Herbal" }));
    await screen.findByText("Rooibos");
    await user.click(screen.getByRole("tab", { name: "Green" }));
    expect(screen.getByText("Gyokuro")).toBeInTheDocument();
    expect(screen.queryByText("Sencha")).not.toBeInTheDocument();
    await flush();
    expect(fetch.calls).toHaveLength(3);
    
  9. a failed load shows an error, opening the tab again doesn't retry, and Refresh does
    const menu = {
      green: [{ id: 1, name: "Sencha" }, { id: 2, name: "Matcha" }],
      black: [{ id: 3, name: "Assam" }],
      herbal: [{ id: 4, name: "Rooibos" }],
    };
    const typeIn = (url) => url.split("type=")[1];
    let greens = 0;
    const fetch = mockFetch((url) => (typeIn(url) !== "green" ? menu[typeIn(url)] : ++greens === 1 ? fail(500) : menu.green));
    render(<App />);
    expect(await screen.findByText("Couldn't load these teas.")).toBeInTheDocument();
    await user.click(screen.getByRole("tab", { name: "Black" }));
    await screen.findByText("Assam");
    await user.click(screen.getByRole("tab", { name: "Green" }));
    expect(screen.getByText("Couldn't load these teas.")).toBeInTheDocument();
    await flush();
    expect(fetch.calls.map((call) => typeIn(call.url))).toEqual(["green", "black"]);
    await user.click(screen.getByRole("button", { name: "Refresh" }));
    expect(await screen.findByText("Sencha")).toBeInTheDocument();
    expect(screen.queryByText("Couldn't load these teas.")).not.toBeInTheDocument();
    
  10. a failed Refresh keeps the saved list on screen
    const menu = {
      green: [{ id: 1, name: "Sencha" }, { id: 2, name: "Matcha" }],
      black: [{ id: 3, name: "Assam" }],
      herbal: [{ id: 4, name: "Rooibos" }],
    };
    const typeIn = (url) => url.split("type=")[1];
    let greens = 0;
    mockFetch(() => (++greens === 1 ? menu.green : fail(500)));
    render(<App />);
    await screen.findByText("Sencha");
    await user.click(screen.getByRole("button", { name: "Refresh" }));
    expect(await screen.findByText("Couldn't load these teas.")).toBeInTheDocument();
    expect(screen.getByText("Sencha")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled();
    

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).