Skip to the code
cf.

Memoize an expensive filter

The tea shop's search page filters a long catalogue with filterTeas, which is slow. The page also has an order note box. Typing a note has nothing to do with the search, but every keystroke renders the page, and the page runs the filter again. Make the page run the filter only when the teas or the search text change.

filterTeas(teas, query) is exported. It returns the teas whose name contains query, ignoring case and spaces at either end; an empty query returns every tea. The starter's version is right: keep it exported and working.

TeaSearch takes teas, a list of { id, name }, and filter, the function it filters with, which is filterTeas by default. The tests pass a spy wrapped around filterTeas to count the calls, so always call filter, not filterTeas directly. The page has a text box labelled Search teas, a list with one <li> per matching tea showing its name, and a text box labelled Order note. When no tea matches, it shows No teas found instead of the list.

What to fix: typing a note doesn't call filter. Each change to the search text calls it once, and so does a new teas list. Rendering TeaSearch again with the same teas and filter doesn't call it.

Export TeaSearch by name. The default export, App, renders it with a filter that logs each call for the Preview.

filterTeas([{ id: "sencha", name: "Sencha" }, { id: "assam", name: "Assam" }], " SEN "); // [{ id: "sencha", name: "Sencha" }]
 
<TeaSearch teas={teas} />
// renders:
<label>Search teas <input value="" /></label>
<ul>
  <li>Sencha</li>
  <li>Assam</li>
</ul>
<label>Order note <input value="" /></label>

What the tests check

  1. filterTeas matches names, ignoring case and spaces at either end
    const teas = [
      { id: "sencha", name: "Sencha" },
      { id: "assam", name: "Assam" },
      { id: "genmaicha", name: "Genmaicha" },
    ];
    expect(filterTeas(teas, " CHA ")).toEqual([{ id: "sencha", name: "Sencha" }, { id: "genmaicha", name: "Genmaicha" }]);
    expect(filterTeas(teas, "")).toEqual(teas);
    expect(filterTeas(teas, "mint")).toEqual([]);
    
  2. shows every tea at first, calling filter once
    const teas = [
      { id: "sencha", name: "Sencha" },
      { id: "assam", name: "Assam" },
    ];
    const filter = spy(filterTeas);
    render(<TeaSearch teas={teas} filter={filter} />);
    expect(screen.getAllByRole("listitem")).toHaveLength(2);
    expect(filter.callCount).toBe(1);
    expect(filter.calls[0].args).toEqual([teas, ""]);
    
  3. filters as you type
    const teas = [
      { id: "sencha", name: "Sencha" },
      { id: "assam", name: "Assam" },
      { id: "genmaicha", name: "Genmaicha" },
    ];
    render(<TeaSearch 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("Assam")).not.toBeInTheDocument();
    
  4. 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();
    expect(screen.queryByRole("listitem")).not.toBeInTheDocument();
    
  5. typing a note doesn't call filter again
    const filter = spy(filterTeas);
    render(<TeaSearch teas={[{ id: "sencha", name: "Sencha" }]} filter={filter} />);
    await user.type(screen.getByLabelText("Order note"), "no sugar");
    expect(screen.getByLabelText("Order note")).toHaveValue("no sugar");
    expect(filter.callCount).toBe(1);
    
  6. each change to the search text calls filter once
    const teas = [
      { id: "sencha", name: "Sencha" },
      { id: "assam", name: "Assam" },
    ];
    const filter = spy(filterTeas);
    render(<TeaSearch teas={teas} filter={filter} />);
    await user.type(screen.getByLabelText("Search teas"), "as");
    expect(filter.callCount).toBe(3);
    expect(filter.calls[2].args).toEqual([teas, "as"]);
    expect(screen.getAllByRole("listitem")).toHaveLength(1);
    
  7. a new teas list filters again
    const filter = spy(filterTeas);
    const { rerender } = render(<TeaSearch teas={[{ id: "sencha", name: "Sencha" }]} filter={filter} />);
    await user.type(screen.getByLabelText("Search teas"), "s");
    rerender(<TeaSearch teas={[{ id: "assam", name: "Assam" }, { id: "oolong", name: "Oolong" }]} filter={filter} />);
    expect(screen.getByText("Assam")).toBeInTheDocument();
    expect(screen.queryByText("Sencha")).not.toBeInTheDocument();
    expect(screen.queryByText("Oolong")).not.toBeInTheDocument();
    expect(filter.callCount).toBe(3);
    
  8. rendering again with the same props doesn't call filter
    const teas = [{ id: "sencha", name: "Sencha" }];
    const filter = spy(filterTeas);
    const { rerender } = render(<TeaSearch teas={teas} filter={filter} />);
    rerender(<TeaSearch teas={teas} filter={filter} />);
    rerender(<TeaSearch teas={teas} filter={filter} />);
    expect(filter.callCount).toBe(1);
    

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