Skip to the code
cf.

Rows that skip rendering

The tea shop's menu lists every tea with a star button for favourites, and a filter box above the list. It works, but every keystroke in the filter renders every row again, and starring one tea renders all of them. With hundreds of teas, typing starts to lag. The starter is that working code. Change it so a row renders only when something it shows has changed.

Keep what it does. TeaMenu takes teas, a list of { id, name, price }, and an optional onRowRender. A text box labelled Filter teas shows only the teas whose name contains the text, ignoring case. Each tea is an <li> with its name, its price as $4.00, and a Star button whose accessible name is Star plus the tea's name, like Star Sencha, with aria-pressed saying whether that tea is starred. Clicking it stars or unstars the tea. A star stays while the filter hides the tea and shows it again.

The row component calls onRowRender(tea.id) every time it renders. That line is already in the starter's TeaRow, and it's how the tests count renders, so keep it in the row.

What to fix: when the filter text changes, a row that was on screen and still is doesn't render again. Starring or unstarring a tea renders that one row once, and no other row. Rendering TeaMenu again with the same props renders no row at all. A row the filter hides and brings back is a new row, so it renders once when it comes back.

The tests render without Strict Mode, so each update means exactly one render. Export TeaMenu by name. The default export, App, renders it with sample teas and logs each row render for the Preview; the tests don't use it.

<TeaMenu teas={teas} onRowRender={(id) => console.log("rendered", id)} />
 
// renders, with Sencha starred:
<label>Filter teas <input value="" /></label>
<ul>
  <li><span>Sencha</span> <span>$4.00</span> <button aria-label="Star Sencha" aria-pressed="true">Star</button></li>
  <li><span>Assam</span> <span>$3.50</span> <button aria-label="Star Assam" aria-pressed="false">Star</button></li>
</ul>

What the tests check

  1. shows each tea with its price and a Star button
    const teas = [
      { id: "sencha", name: "Sencha", price: 4 },
      { id: "assam", name: "Assam", price: 3.5 },
    ];
    render(<TeaMenu teas={teas} />);
    const items = screen.getAllByRole("listitem");
    expect(items).toHaveLength(2);
    expect(within(items[0]).getByText("Sencha")).toBeInTheDocument();
    expect(within(items[0]).getByText("$4.00")).toBeInTheDocument();
    expect(within(items[1]).getByText("$3.50")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Star Assam" })).toHaveAttribute("aria-pressed", "false");
    
  2. filters by name as you type, ignoring case
    const teas = [
      { id: "sencha", name: "Sencha", price: 4 },
      { id: "assam", name: "Assam", price: 3.5 },
      { id: "oolong", name: "Milk Oolong", price: 6 },
      { id: "rooibos", name: "Rooibos", price: 3 },
    ];
    render(<TeaMenu teas={teas} />);
    await user.type(screen.getByLabelText("Filter teas"), "OO");
    expect(screen.getAllByRole("listitem")).toHaveLength(2);
    expect(screen.getByText("Milk Oolong")).toBeInTheDocument();
    expect(screen.getByText("Rooibos")).toBeInTheDocument();
    expect(screen.queryByText("Sencha")).not.toBeInTheDocument();
    
  3. Star toggles a tea, and the star survives filtering
    const teas = [
      { id: "sencha", name: "Sencha", price: 4 },
      { id: "assam", name: "Assam", price: 3.5 },
    ];
    render(<TeaMenu teas={teas} />);
    await user.click(screen.getByRole("button", { name: "Star Sencha" }));
    expect(screen.getByRole("button", { name: "Star Sencha" })).toHaveAttribute("aria-pressed", "true");
    expect(screen.getByRole("button", { name: "Star Assam" })).toHaveAttribute("aria-pressed", "false");
    const filter = screen.getByLabelText("Filter teas");
    await user.type(filter, "assam");
    expect(screen.queryByText("Sencha")).not.toBeInTheDocument();
    await user.clear(filter);
    expect(screen.getByRole("button", { name: "Star Sencha" })).toHaveAttribute("aria-pressed", "true");
    await user.click(screen.getByRole("button", { name: "Star Sencha" }));
    expect(screen.getByRole("button", { name: "Star Sencha" })).toHaveAttribute("aria-pressed", "false");
    
  4. renders each row once at first
    const teas = [
      { id: "sencha", name: "Sencha", price: 4 },
      { id: "assam", name: "Assam", price: 3.5 },
      { id: "rooibos", name: "Rooibos", price: 3 },
    ];
    const renders = {};
    render(<TeaMenu teas={teas} onRowRender={(id) => (renders[id] = (renders[id] ?? 0) + 1)} />);
    expect(renders).toEqual({ sencha: 1, assam: 1, rooibos: 1 });
    
  5. typing in the filter doesn't render the rows that stay
    const teas = [
      { id: "sencha", name: "Sencha", price: 4 },
      { id: "assam", name: "Assam", price: 3.5 },
      { id: "oolong", name: "Milk Oolong", price: 6 },
      { id: "rooibos", name: "Rooibos", price: 3 },
    ];
    const renders = {};
    render(<TeaMenu teas={teas} onRowRender={(id) => (renders[id] = (renders[id] ?? 0) + 1)} />);
    await user.type(screen.getByLabelText("Filter teas"), "oo");
    expect(screen.getAllByRole("listitem")).toHaveLength(2);
    expect(renders.oolong).toBe(1);
    expect(renders.rooibos).toBe(1);
    
  6. starring a tea renders only that row
    const teas = [
      { id: "sencha", name: "Sencha", price: 4 },
      { id: "assam", name: "Assam", price: 3.5 },
      { id: "rooibos", name: "Rooibos", price: 3 },
    ];
    const renders = {};
    render(<TeaMenu teas={teas} onRowRender={(id) => (renders[id] = (renders[id] ?? 0) + 1)} />);
    await user.click(screen.getByRole("button", { name: "Star Assam" }));
    expect(renders).toEqual({ sencha: 1, assam: 2, rooibos: 1 });
    await user.click(screen.getByRole("button", { name: "Star Sencha" }));
    expect(renders).toEqual({ sencha: 2, assam: 2, rooibos: 1 });
    await user.click(screen.getByRole("button", { name: "Star Assam" }));
    expect(renders).toEqual({ sencha: 2, assam: 3, rooibos: 1 });
    expect(screen.getByRole("button", { name: "Star Assam" })).toHaveAttribute("aria-pressed", "false");
    
  7. rendering TeaMenu again with the same props renders no row
    const teas = [
      { id: "sencha", name: "Sencha", price: 4 },
      { id: "assam", name: "Assam", price: 3.5 },
    ];
    const renders = {};
    const onRowRender = (id) => (renders[id] = (renders[id] ?? 0) + 1);
    const { rerender } = render(<TeaMenu teas={teas} onRowRender={onRowRender} />);
    await user.click(screen.getByRole("button", { name: "Star Sencha" }));
    rerender(<TeaMenu teas={teas} onRowRender={onRowRender} />);
    rerender(<TeaMenu teas={teas} onRowRender={onRowRender} />);
    expect(renders).toEqual({ sencha: 2, assam: 1 });
    
  8. a row the filter brings back renders once when it returns
    const teas = [
      { id: "sencha", name: "Sencha", price: 4 },
      { id: "oolong", name: "Milk Oolong", price: 6 },
      { id: "rooibos", name: "Rooibos", price: 3 },
    ];
    const renders = {};
    render(<TeaMenu teas={teas} onRowRender={(id) => (renders[id] = (renders[id] ?? 0) + 1)} />);
    const filter = screen.getByLabelText("Filter teas");
    await user.type(filter, "oo");
    await user.clear(filter);
    expect(screen.getAllByRole("listitem")).toHaveLength(3);
    expect(renders).toEqual({ sencha: 2, oolong: 1, rooibos: 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).