Skip to the code
cf.

Fix the effect loop

A colleague wrote TeaFinder for the tea shop's quick order page, and it doesn't work: the page crashes as soon as it opens, with Maximum update depth exceeded, and the Added notice doesn't go away on time. The starter is their code. Find the two bugs and fix them, keeping the same markup.

TeaFinder takes teas, a list of { id, name }, and noticeMs, how long the notice stays (2000 by default). A text box labelled Search teas filters the list: it shows the teas whose name contains the search text, ignoring case and spaces at either end. An empty search shows every tea. Deleting letters brings teas back, and a new teas prop shows the new teas.

Each tea is an <li> with its name in a <span> and a button named Add plus the tea's name, like Add Sencha. Clicking it shows Added Sencha in a <p role="status">, which goes back to empty noticeMs after the click, even if you keep typing in the search box in the meantime.

Export TeaFinder by name. The default export, App, renders it with a few teas for the Preview, and the tests don't use it.

<TeaFinder teas={[{ id: "sencha", name: "Sencha" }, { id: "rooibos", name: "Rooibos" }]} noticeMs={1500} />
 
// renders, after searching "sen" and adding Sencha:
<label for="...">Search teas</label>
<input id="..." value="sen" />
<ul>
  <li><span>Sencha</span> <button aria-label="Add Sencha">Add</button></li>
</ul>
<p role="status">Added Sencha</p>

What the tests check

  1. shows every tea at first
    const teas = [
      { id: "sencha", name: "Sencha" },
      { id: "earl-grey", name: "Earl Grey" },
      { id: "genmaicha", name: "Genmaicha" },
      { id: "rooibos", name: "Rooibos" },
    ];
    render(<TeaFinder teas={teas} />);
    expect(screen.getAllByRole("listitem")).toHaveLength(4);
    for (const tea of teas) expect(screen.getByRole("button", { name: "Add " + tea.name })).toBeInTheDocument();
    
  2. filters the list as you type
    const teas = [
      { id: "sencha", name: "Sencha" },
      { id: "earl-grey", name: "Earl Grey" },
      { id: "genmaicha", name: "Genmaicha" },
      { id: "rooibos", name: "Rooibos" },
    ];
    render(<TeaFinder teas={teas} />);
    await user.type(screen.getByLabelText("Search teas"), "cha");
    expect(screen.getAllByRole("listitem")).toHaveLength(2);
    expect(screen.getByText("Sencha")).toBeInTheDocument();
    expect(screen.getByText("Genmaicha")).toBeInTheDocument();
    expect(screen.queryByText("Earl Grey")).not.toBeInTheDocument();
    
  3. ignores case and spaces at either end
    const teas = [
      { id: "sencha", name: "Sencha" },
      { id: "earl-grey", name: "Earl Grey" },
      { id: "rooibos", name: "Rooibos" },
    ];
    render(<TeaFinder teas={teas} />);
    await user.type(screen.getByLabelText("Search teas"), "  EARL ");
    expect(screen.getAllByRole("listitem")).toHaveLength(1);
    expect(screen.getByText("Earl Grey")).toBeInTheDocument();
    
  4. brings teas back when you delete letters
    const teas = [
      { id: "sencha", name: "Sencha" },
      { id: "earl-grey", name: "Earl Grey" },
      { id: "genmaicha", name: "Genmaicha" },
      { id: "rooibos", name: "Rooibos" },
    ];
    render(<TeaFinder teas={teas} />);
    const search = screen.getByLabelText("Search teas");
    await user.type(search, "sen");
    expect(screen.getAllByRole("listitem")).toHaveLength(1);
    await user.type(search, "{Backspace}{Backspace}");
    expect(screen.getAllByRole("listitem")).toHaveLength(2);
    await user.clear(search);
    expect(screen.getAllByRole("listitem")).toHaveLength(4);
    
  5. shows the new teas when the teas prop changes
    const { rerender } = render(<TeaFinder teas={[{ id: "sencha", name: "Sencha" }]} />);
    rerender(<TeaFinder teas={[{ id: "oolong", name: "Oolong" }, { id: "assam", name: "Assam" }]} />);
    expect(screen.getAllByRole("listitem")).toHaveLength(2);
    expect(screen.getByText("Oolong")).toBeInTheDocument();
    expect(screen.queryByText("Sencha")).not.toBeInTheDocument();
    
  6. shows a notice when you add a tea
    render(<TeaFinder teas={[{ id: "sencha", name: "Sencha" }, { id: "rooibos", name: "Rooibos" }]} />);
    expect(screen.getByRole("status").textContent).toBe("");
    await user.click(screen.getByRole("button", { name: "Add Rooibos" }));
    expect(screen.getByRole("status").textContent).toBe("Added Rooibos");
    
  7. hides the notice after noticeMs
    const clock = useFakeTimers();
    render(<TeaFinder teas={[{ id: "sencha", name: "Sencha" }]} noticeMs={1000} />);
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    await clock.advance(999);
    expect(screen.getByRole("status").textContent).toBe("Added Sencha");
    await clock.advance(1);
    expect(screen.getByRole("status").textContent).toBe("");
    
  8. typing doesn't delay hiding the notice
    const clock = useFakeTimers();
    render(<TeaFinder teas={[{ id: "sencha", name: "Sencha" }, { id: "rooibos", name: "Rooibos" }]} />);
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    await clock.advance(1000);
    await user.type(screen.getByLabelText("Search teas"), "s");
    await clock.advance(500);
    await user.type(screen.getByLabelText("Search teas"), "e");
    await clock.advance(500);
    expect(screen.getByRole("status").textContent).toBe("");
    

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