Skip to the code
cf.

Load more feed

Build Feed, the default export: the news feed of a small tea shop's blog. When it appears, it fetches /api/posts?page=1. Every answer is JSON like { posts: [{ id: 1, title: "New oolong in stock" }], hasMore: true }.

Show every post's title in one list, one item each, in the order they came. Under it, a Load more button fetches the next page (page=2, then page=3, and so on) and adds its posts to the end of the list.

While a page loads, show Loading… (with the single … character) and disable Load more. A double click must fetch the next page only once. When an answer says hasMore: false, remove the button: there's nothing left to load.

New posts can push older ones onto the next page, so a page may repeat a post you already have (the same id). Show each post only once.

If a page fails to load (fetch rejects, or the response isn't ok), show Couldn't load posts. and keep the posts you have. Load more then asks for the same page again.

<ul>
  <li>New oolong in stock</li>
  <li>How we roast our hojicha</li>
</ul>
<button>Load more</button>   // gone once hasMore is false

What the tests check

  1. fetches page 1 when it appears, showing Loading… until it arrives
    const postsOf = (n, hasMore = n < 3) => ({
      posts: [
        { id: n * 10 + 1, title: "Post " + n + "a" },
        { id: n * 10 + 2, title: "Post " + n + "b" },
      ],
      hasMore,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    const answer = deferred();
    const fetch = mockFetch(() => answer.promise);
    render(<App />);
    expect(screen.getByText("Loading…")).toBeInTheDocument();
    expect(fetch.calls.map((call) => call.url)).toEqual(["/api/posts?page=1"]);
    answer.resolve(postsOf(1));
    const items = await screen.findAllByRole("listitem");
    expect(items.map((item) => item.textContent)).toEqual(["Post 1a", "Post 1b"]);
    expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
    
  2. Load more adds the next page to the end
    const postsOf = (n, hasMore = n < 3) => ({
      posts: [
        { id: n * 10 + 1, title: "Post " + n + "a" },
        { id: n * 10 + 2, title: "Post " + n + "b" },
      ],
      hasMore,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    const fetch = mockFetch((url) => postsOf(pageIn(url)));
    render(<App />);
    await screen.findByText("Post 1a");
    await user.click(screen.getByRole("button", { name: "Load more" }));
    await screen.findByText("Post 2a");
    const items = screen.getAllByRole("listitem");
    expect(items.map((item) => item.textContent)).toEqual(["Post 1a", "Post 1b", "Post 2a", "Post 2b"]);
    expect(fetch.calls.map((call) => call.url)).toEqual(["/api/posts?page=1", "/api/posts?page=2"]);
    
  3. keeps going one page at a time
    const postsOf = (n, hasMore = n < 3) => ({
      posts: [
        { id: n * 10 + 1, title: "Post " + n + "a" },
        { id: n * 10 + 2, title: "Post " + n + "b" },
      ],
      hasMore,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    const fetch = mockFetch((url) => postsOf(pageIn(url), true));
    render(<App />);
    await screen.findByText("Post 1a");
    await user.click(screen.getByRole("button", { name: "Load more" }));
    await screen.findByText("Post 2a");
    await user.click(screen.getByRole("button", { name: "Load more" }));
    await screen.findByText("Post 3a");
    expect(screen.getAllByRole("listitem")).toHaveLength(6);
    expect(fetch.calls.map((call) => pageIn(call.url))).toEqual([1, 2, 3]);
    
  4. disables Load more and shows Loading… while a page loads
    const postsOf = (n, hasMore = n < 3) => ({
      posts: [
        { id: n * 10 + 1, title: "Post " + n + "a" },
        { id: n * 10 + 2, title: "Post " + n + "b" },
      ],
      hasMore,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    const second = deferred();
    mockFetch((url) => (pageIn(url) === 2 ? second.promise : postsOf(1)));
    render(<App />);
    await screen.findByText("Post 1a");
    await user.click(screen.getByRole("button", { name: "Load more" }));
    expect(screen.getByRole("button", { name: "Load more" })).toBeDisabled();
    expect(screen.getByText("Loading…")).toBeInTheDocument();
    expect(screen.getByText("Post 1a")).toBeInTheDocument();
    second.resolve(postsOf(2));
    await screen.findByText("Post 2a");
    expect(screen.getByRole("button", { name: "Load more" })).toBeEnabled();
    expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
    
  5. a double click fetches the next page once
    const postsOf = (n, hasMore = n < 3) => ({
      posts: [
        { id: n * 10 + 1, title: "Post " + n + "a" },
        { id: n * 10 + 2, title: "Post " + n + "b" },
      ],
      hasMore,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    const second = deferred();
    const fetch = mockFetch((url) => (pageIn(url) === 2 ? second.promise : postsOf(pageIn(url))));
    render(<App />);
    await screen.findByText("Post 1a");
    await user.dblClick(screen.getByRole("button", { name: "Load more" }));
    second.resolve(postsOf(2));
    await screen.findByText("Post 2a");
    await flush();
    expect(fetch.calls.map((call) => pageIn(call.url))).toEqual([1, 2]);
    const items = screen.getAllByRole("listitem");
    expect(items.map((item) => item.textContent)).toEqual(["Post 1a", "Post 1b", "Post 2a", "Post 2b"]);
    
  6. removes Load more when there's nothing left
    const postsOf = (n, hasMore = n < 3) => ({
      posts: [
        { id: n * 10 + 1, title: "Post " + n + "a" },
        { id: n * 10 + 2, title: "Post " + n + "b" },
      ],
      hasMore,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    mockFetch((url) => postsOf(pageIn(url), pageIn(url) < 2));
    render(<App />);
    await screen.findByText("Post 1a");
    await user.click(screen.getByRole("button", { name: "Load more" }));
    await screen.findByText("Post 2a");
    expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
    
  7. shows a post only once when a page repeats it
    const postsOf = (n, hasMore = n < 3) => ({
      posts: [
        { id: n * 10 + 1, title: "Post " + n + "a" },
        { id: n * 10 + 2, title: "Post " + n + "b" },
      ],
      hasMore,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    mockFetch((url) =>
      pageIn(url) === 1
        ? postsOf(1)
        : { posts: [{ id: 12, title: "Post 1b" }, { id: 21, title: "Post 2a" }], hasMore: true },
    );
    render(<App />);
    await screen.findByText("Post 1a");
    await user.click(screen.getByRole("button", { name: "Load more" }));
    await screen.findByText("Post 2a");
    const items = screen.getAllByRole("listitem");
    expect(items.map((item) => item.textContent)).toEqual(["Post 1a", "Post 1b", "Post 2a"]);
    
  8. a failed page shows an error, keeps the posts, and Load more asks again
    const postsOf = (n, hasMore = n < 3) => ({
      posts: [
        { id: n * 10 + 1, title: "Post " + n + "a" },
        { id: n * 10 + 2, title: "Post " + n + "b" },
      ],
      hasMore,
    });
    const pageIn = (url) => Number(url.split("page=")[1]);
    let tries = 0;
    const fetch = mockFetch((url) => (pageIn(url) === 2 && ++tries === 1 ? fail(500) : postsOf(pageIn(url))));
    render(<App />);
    await screen.findByText("Post 1a");
    await user.click(screen.getByRole("button", { name: "Load more" }));
    expect(await screen.findByText("Couldn't load posts.")).toBeInTheDocument();
    expect(screen.getAllByRole("listitem")).toHaveLength(2);
    await user.click(screen.getByRole("button", { name: "Load more" }));
    await screen.findByText("Post 2a");
    expect(screen.queryByText("Couldn't load posts.")).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).