Skip to the code
cf.

Search without races

Build App: a search box labelled Search teas. Every time its text changes, fetch /api/teas?q= followed by the text, encoded with encodeURIComponent. The response is JSON: an array of teas like { id: 1, name: "Green" }.

While the latest search is waiting for its answer, show Loading… (with the single … character). Then list the tea names in a list, one item each, or show No teas match. when the array is empty.

If the request fails (fetch rejects, or the response isn't ok), show Something went wrong. and a Retry button that runs the same search again. When the box is empty, show nothing at all and don't fetch.

Answers can arrive in any order. Only the latest search counts: an older one that answers late, with teas or with an error, must never change the page. Abort the older request, ignore its answer, or both.

// Typing "g", then "r":
// GET /api/teas?q=g    answers last, with [Ginger]: never shown
// GET /api/teas?q=gr   answers first, with [Green]: shown

What the tests check

  1. fetches /api/teas?q=… each time the text changes
    const fetch = mockFetch(() => []);
    render(<App />);
    await user.type(screen.getByLabelText("Search teas"), "gr");
    expect(fetch.calls.map((call) => call.url)).toEqual(["/api/teas?q=g", "/api/teas?q=gr"]);
    
  2. encodes the text in the URL
    const fetch = mockFetch(() => []);
    render(<App />);
    await user.type(screen.getByLabelText("Search teas"), "earl grey");
    expect(fetch.calls[fetch.calls.length - 1].url).toBe("/api/teas?q=earl%20grey");
    
  3. shows Loading… until the answer arrives
    const answer = deferred();
    mockFetch(() => answer.promise);
    render(<App />);
    await user.type(screen.getByLabelText("Search teas"), "g");
    expect(screen.getByText("Loading…")).toBeInTheDocument();
    answer.resolve([{ id: 1, name: "Green" }]);
    expect(await screen.findByText("Green")).toBeInTheDocument();
    expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
    
  4. lists the teas that come back
    mockFetch(() => [{ id: 1, name: "Green" }, { id: 2, name: "Genmaicha" }]);
    render(<App />);
    await user.type(screen.getByLabelText("Search teas"), "g");
    const items = await screen.findAllByRole("listitem");
    expect(items.map((item) => item.textContent)).toEqual(["Green", "Genmaicha"]);
    
  5. says when nothing matches
    mockFetch(() => []);
    render(<App />);
    await user.type(screen.getByLabelText("Search teas"), "zz");
    expect(await screen.findByText("No teas match.")).toBeInTheDocument();
    
  6. an empty box shows nothing and fetches nothing
    const fetch = mockFetch(() => [{ id: 1, name: "Green" }]);
    render(<App />);
    const box = screen.getByLabelText("Search teas");
    await user.type(box, "g");
    expect(await screen.findByText("Green")).toBeInTheDocument();
    await user.clear(box);
    await flush();
    expect(screen.queryByText("Green")).not.toBeInTheDocument();
    expect(screen.queryByText("No teas match.")).not.toBeInTheDocument();
    expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
    expect(fetch.calls).toHaveLength(1);
    
  7. never shows an older search that answers late
    const first = deferred();
    const second = deferred();
    mockFetch((url) => (url === "/api/teas?q=g" ? first.promise : second.promise));
    render(<App />);
    await user.type(screen.getByLabelText("Search teas"), "gr");
    second.resolve([{ id: 2, name: "Green" }]);
    expect(await screen.findByText("Green")).toBeInTheDocument();
    first.resolve([{ id: 1, name: "Ginger" }]);
    await flush();
    expect(screen.queryByText("Ginger")).not.toBeInTheDocument();
    expect(screen.getByText("Green")).toBeInTheDocument();
    
  8. keeps Loading… while only an older search has answered
    const first = deferred();
    const second = deferred();
    mockFetch((url) => (url === "/api/teas?q=g" ? first.promise : second.promise));
    render(<App />);
    await user.type(screen.getByLabelText("Search teas"), "gr");
    first.resolve([{ id: 1, name: "Ginger" }]);
    await flush();
    expect(screen.queryByText("Ginger")).not.toBeInTheDocument();
    expect(screen.getByText("Loading…")).toBeInTheDocument();
    second.resolve([{ id: 2, name: "Green" }]);
    expect(await screen.findByText("Green")).toBeInTheDocument();
    
  9. an older search that fails late shows no error
    const first = deferred();
    mockFetch((url) => (url === "/api/teas?q=g" ? first.promise : [{ id: 2, name: "Green" }]));
    render(<App />);
    await user.type(screen.getByLabelText("Search teas"), "gr");
    expect(await screen.findByText("Green")).toBeInTheDocument();
    first.resolve(fail(500));
    await flush();
    expect(screen.queryByText("Something went wrong.")).not.toBeInTheDocument();
    expect(screen.getByText("Green")).toBeInTheDocument();
    
  10. a failed search shows an error, and Retry searches again
    let attempts = 0;
    const fetch = mockFetch(() => (++attempts === 1 ? fail(500) : [{ id: 1, name: "Green" }]));
    render(<App />);
    await user.type(screen.getByLabelText("Search teas"), "g");
    expect(await screen.findByText("Something went wrong.")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Retry" }));
    expect(await screen.findByText("Green")).toBeInTheDocument();
    expect(screen.queryByText("Something went wrong.")).not.toBeInTheDocument();
    expect(fetch.calls.map((call) => call.url)).toEqual(["/api/teas?q=g", "/api/teas?q=g"]);
    

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