Skip to the code
cf.

Deferred search results

The tea shop's search shows results that are slow to render. Typing feels sticky, because each letter has to wait for the whole list to render before it appears in the box. Make the box urgent and the list a step behind: React should show each letter at once, then render the results for it when it can, keeping the old results on screen, marked as stale, in the meantime.

TeaSearch takes teas, a list of { id, name }, slowMs, how long each result takes to render (0 by default; the Preview's App sets it so you can feel the lag), and an optional onResultsRender. It has a text box labelled Search teas. Below it, a <section aria-label="Results"> holds SlowResults, which lists the teas whose name contains the search text, ignoring case and spaces at either end, one <li> per tea, or shows No teas found.

SlowResults takes teas, query, slowMs, and onRender, and calls onRender(query) every time it renders: that's how the tests see which search text it rendered with. Keep that line.

What to build: each keystroke renders SlowResults exactly once, with the new search text, and never again with the text it already shows. While the results are behind the box, the section has aria-busy="true" and opacity 0.5. When they've caught up, it has aria-busy="false" and opacity 1. Rendering TeaSearch again with the same props doesn't render SlowResults.

In the tests, React finishes all its work after each keystroke, so the results always catch up in the end. To check the stale marker, the tests look at the section's aria-busy while SlowResults renders with new text: with a deferred value, the box has already been updated and the section already says true. The tests render without Strict Mode. Export TeaSearch and SlowResults by name.

<TeaSearch teas={teas} onResultsRender={(query) => console.log("results for", query)} />
 
// while the results are behind:
<label>Search teas <input value="gre" /></label>
<section aria-label="Results" aria-busy="true" style="opacity: 0.5;">
  <ul><li>Green</li><li>Earl Grey</li></ul>
</section>

What the tests check

  1. shows every tea at first, not busy
    const teas = [{ id: "sencha", name: "Sencha" }, { id: "assam", name: "Assam" }];
    render(<TeaSearch teas={teas} />);
    const results = screen.getByRole("region", { name: "Results" });
    expect(within(results).getAllByRole("listitem")).toHaveLength(2);
    expect(results).toHaveAttribute("aria-busy", "false");
    expect(results.style.opacity).toBe("1");
    
  2. filters as you type, ignoring case and spaces
    const teas = [
      { id: "sencha", name: "Sencha" },
      { id: "jasmine", name: "Jasmine Green" },
      { id: "gunpowder", name: "Gunpowder Green" },
    ];
    render(<TeaSearch teas={teas} />);
    await user.type(screen.getByLabelText("Search teas"), " GREEN ");
    expect(screen.getByLabelText("Search teas")).toHaveValue(" GREEN ");
    const items = within(screen.getByRole("region", { name: "Results" })).getAllByRole("listitem");
    expect(items.map((item) => item.textContent)).toEqual(["Jasmine Green", "Gunpowder Green"]);
    
  3. shows No teas found when nothing matches
    render(<TeaSearch teas={[{ id: "sencha", name: "Sencha" }]} />);
    await user.type(screen.getByLabelText("Search teas"), "mint");
    expect(screen.getByText("No teas found")).toBeInTheDocument();
    
  4. renders the results once per keystroke, with the new text
    const teas = [{ id: "sencha", name: "Sencha" }, { id: "green", name: "Green" }];
    const seen = [];
    render(<TeaSearch teas={teas} onResultsRender={(query) => seen.push(query)} />);
    await user.type(screen.getByLabelText("Search teas"), "gre");
    expect(seen).toEqual(["", "g", "gr", "gre"]);
    
  5. marks the old results busy and faded while new ones render
    const teas = [{ id: "sencha", name: "Sencha" }, { id: "green", name: "Green" }];
    const marks = [];
    render(
      <TeaSearch
        teas={teas}
        onResultsRender={(query) => {
          const section = document.querySelector("section");
          if (section) marks.push([query, section.getAttribute("aria-busy"), section.style.opacity]);
        }}
      />,
    );
    await user.type(screen.getByLabelText("Search teas"), "gr");
    expect(marks).toEqual([
      ["g", "true", "0.5"],
      ["gr", "true", "0.5"],
    ]);
    
  6. is no longer busy once the results catch up
    const teas = [{ id: "sencha", name: "Sencha" }, { id: "green", name: "Green" }];
    render(<TeaSearch teas={teas} />);
    await user.type(screen.getByLabelText("Search teas"), "sen");
    const results = screen.getByRole("region", { name: "Results" });
    expect(results).toHaveAttribute("aria-busy", "false");
    expect(results.style.opacity).toBe("1");
    expect(within(results).getAllByRole("listitem").map((item) => item.textContent)).toEqual(["Sencha"]);
    
  7. rendering TeaSearch again with the same props doesn't render the results
    const teas = [{ id: "sencha", name: "Sencha" }];
    let renders = 0;
    const onResultsRender = () => renders++;
    const { rerender } = render(<TeaSearch teas={teas} onResultsRender={onResultsRender} />);
    rerender(<TeaSearch teas={teas} onResultsRender={onResultsRender} />);
    expect(renders).toBe(1);
    
  8. a new teas list shows new results
    const { rerender } = render(<TeaSearch teas={[{ id: "sencha", name: "Sencha" }]} />);
    await user.type(screen.getByLabelText("Search teas"), "o");
    expect(screen.getByText("No teas found")).toBeInTheDocument();
    rerender(<TeaSearch teas={[{ id: "oolong", name: "Oolong" }, { id: "assam", name: "Assam" }]} />);
    expect(screen.getByText("Oolong")).toBeInTheDocument();
    expect(screen.queryByText("Assam")).not.toBeInTheDocument();
    

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