Skip to the code
cf.

Paginated list

Build OrderPages, the default export: a small tea shop's order history, one page at a time. It fetches /api/orders?page=1 first. Every answer is JSON like { orders: [{ id: 11, item: "Sencha, 100 g" }], totalPages: 3 }.

Until the first page arrives, show only Loading… (with the single … character). Then show each order's item in a list, one item each, the text Page 1 of 3, and two buttons, Previous and Next.

Next asks for the next page and Previous for the one before, counting from the page you asked for last, even if it hasn't arrived yet. Previous is disabled on page 1, and Next on the last page, using totalPages from the latest answer.

While a page loads, keep the orders you have on screen and mark their list with aria-busy="true". Dim it too, any way you like: the tests only look at aria-busy. Page 1 of 3 keeps describing the orders on screen until the new ones arrive. Answers can arrive in any order: only the page you asked for last may show.

If a page fails to load (fetch rejects, or the response isn't ok), show Couldn't load page 2. (with that page's number) and a Retry button that asks for it again. Orders already on screen stay there.

// While page 2 loads:
<ul aria-busy="true" style="opacity: 0.5">
  <li>Sencha, 100 g</li>
  ...
</ul>
<p>Page 1 of 3</p>
<button>Previous</button>   // disabled on page 1
<button>Next</button>

What the tests check

  1. fetches page 1 and shows Loading… until it arrives
    const answer = deferred();
    const fetch = mockFetch(() => answer.promise);
    render(<App />);
    expect(screen.getByText("Loading…")).toBeInTheDocument();
    expect(fetch.calls.map((call) => call.url)).toEqual(["/api/orders?page=1"]);
    answer.resolve({ orders: [{ id: 11, item: "Sencha, 100 g" }], totalPages: 3 });
    expect(await screen.findByText("Sencha, 100 g")).toBeInTheDocument();
    expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
    
  2. lists the orders and shows Page 1 of 3
    const pageOf = (n, totalPages = 3) => ({
      orders: [
        { id: n * 10 + 1, item: "Order " + n + "a" },
        { id: n * 10 + 2, item: "Order " + n + "b" },
      ],
      totalPages,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    mockFetch((url) => pageOf(pageIn(url)));
    render(<App />);
    const items = await screen.findAllByRole("listitem");
    expect(items.map((item) => item.textContent)).toEqual(["Order 1a", "Order 1b"]);
    expect(screen.getByText("Page 1 of 3")).toBeInTheDocument();
    
  3. disables Previous on the first page
    const pageOf = (n, totalPages = 3) => ({
      orders: [
        { id: n * 10 + 1, item: "Order " + n + "a" },
        { id: n * 10 + 2, item: "Order " + n + "b" },
      ],
      totalPages,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    mockFetch((url) => pageOf(pageIn(url)));
    render(<App />);
    await screen.findByText("Order 1a");
    expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled();
    expect(screen.getByRole("button", { name: "Next" })).toBeEnabled();
    
  4. Next fetches and shows the next page
    const pageOf = (n, totalPages = 3) => ({
      orders: [
        { id: n * 10 + 1, item: "Order " + n + "a" },
        { id: n * 10 + 2, item: "Order " + n + "b" },
      ],
      totalPages,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    const fetch = mockFetch((url) => pageOf(pageIn(url)));
    render(<App />);
    await screen.findByText("Order 1a");
    await user.click(screen.getByRole("button", { name: "Next" }));
    expect(await screen.findByText("Page 2 of 3")).toBeInTheDocument();
    const items = screen.getAllByRole("listitem");
    expect(items.map((item) => item.textContent)).toEqual(["Order 2a", "Order 2b"]);
    expect(fetch.calls.map((call) => call.url)).toEqual(["/api/orders?page=1", "/api/orders?page=2"]);
    
  5. keeps the old page on screen, busy, while the next one loads
    const pageOf = (n, totalPages = 3) => ({
      orders: [
        { id: n * 10 + 1, item: "Order " + n + "a" },
        { id: n * 10 + 2, item: "Order " + n + "b" },
      ],
      totalPages,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    const second = deferred();
    mockFetch((url) => (pageIn(url) === 2 ? second.promise : pageOf(1)));
    render(<App />);
    await screen.findByText("Order 1a");
    await user.click(screen.getByRole("button", { name: "Next" }));
    expect(screen.getByText("Order 1a")).toBeInTheDocument();
    expect(screen.getByText("Page 1 of 3")).toBeInTheDocument();
    expect(screen.getByRole("list")).toHaveAttribute("aria-busy", "true");
    second.resolve(pageOf(2));
    expect(await screen.findByText("Order 2a")).toBeInTheDocument();
    expect(screen.queryByText("Order 1a")).not.toBeInTheDocument();
    expect(screen.getByRole("list")).not.toHaveAttribute("aria-busy", "true");
    
  6. disables Next on the last page
    const pageOf = (n, totalPages = 3) => ({
      orders: [
        { id: n * 10 + 1, item: "Order " + n + "a" },
        { id: n * 10 + 2, item: "Order " + n + "b" },
      ],
      totalPages,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    mockFetch((url) => pageOf(pageIn(url), 2));
    render(<App />);
    await screen.findByText("Order 1a");
    await user.click(screen.getByRole("button", { name: "Next" }));
    expect(await screen.findByText("Page 2 of 2")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
    expect(screen.getByRole("button", { name: "Previous" })).toBeEnabled();
    
  7. Previous goes back a page
    const pageOf = (n, totalPages = 3) => ({
      orders: [
        { id: n * 10 + 1, item: "Order " + n + "a" },
        { id: n * 10 + 2, item: "Order " + n + "b" },
      ],
      totalPages,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    const fetch = mockFetch((url) => pageOf(pageIn(url)));
    render(<App />);
    await screen.findByText("Order 1a");
    await user.click(screen.getByRole("button", { name: "Next" }));
    await screen.findByText("Order 2a");
    await user.click(screen.getByRole("button", { name: "Previous" }));
    expect(await screen.findByText("Page 1 of 3")).toBeInTheDocument();
    expect(screen.getByText("Order 1a")).toBeInTheDocument();
    expect(fetch.calls.map((call) => pageIn(call.url))).toEqual([1, 2, 1]);
    
  8. Next counts from the page you asked for, even while it loads
    const pageOf = (n, totalPages = 3) => ({
      orders: [
        { id: n * 10 + 1, item: "Order " + n + "a" },
        { id: n * 10 + 2, item: "Order " + n + "b" },
      ],
      totalPages,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    const second = deferred();
    const fetch = mockFetch((url) => (pageIn(url) === 2 ? second.promise : pageOf(pageIn(url))));
    render(<App />);
    await screen.findByText("Order 1a");
    await user.click(screen.getByRole("button", { name: "Next" }));
    await user.click(screen.getByRole("button", { name: "Next" }));
    expect(fetch.calls.map((call) => pageIn(call.url))).toEqual([1, 2, 3]);
    expect(await screen.findByText("Page 3 of 3")).toBeInTheDocument();
    
  9. never shows a page you moved past, even when it answers late
    const pageOf = (n, totalPages = 3) => ({
      orders: [
        { id: n * 10 + 1, item: "Order " + n + "a" },
        { id: n * 10 + 2, item: "Order " + n + "b" },
      ],
      totalPages,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    const second = deferred();
    mockFetch((url) => (pageIn(url) === 2 ? second.promise : pageOf(pageIn(url))));
    render(<App />);
    await screen.findByText("Order 1a");
    await user.click(screen.getByRole("button", { name: "Next" }));
    await user.click(screen.getByRole("button", { name: "Next" }));
    expect(await screen.findByText("Order 3a")).toBeInTheDocument();
    second.resolve(pageOf(2));
    await flush();
    expect(screen.queryByText("Order 2a")).not.toBeInTheDocument();
    expect(screen.getByText("Page 3 of 3")).toBeInTheDocument();
    
  10. a failed page shows an error, and Retry asks for it again
    const pageOf = (n, totalPages = 3) => ({
      orders: [
        { id: n * 10 + 1, item: "Order " + n + "a" },
        { id: n * 10 + 2, item: "Order " + n + "b" },
      ],
      totalPages,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    let tries = 0;
    const fetch = mockFetch((url) => (pageIn(url) === 2 && ++tries === 1 ? fail(500) : pageOf(pageIn(url))));
    render(<App />);
    await screen.findByText("Order 1a");
    await user.click(screen.getByRole("button", { name: "Next" }));
    expect(await screen.findByText("Couldn't load page 2.")).toBeInTheDocument();
    expect(screen.getByText("Order 1a")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Retry" }));
    expect(await screen.findByText("Order 2a")).toBeInTheDocument();
    expect(screen.queryByText("Couldn't load page 2.")).not.toBeInTheDocument();
    expect(fetch.calls.map((call) => pageIn(call.url))).toEqual([1, 2, 2]);
    

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