Skip to the code
cf.

Move state away from a slow chart

The tea shop's dashboard has a search box, a Larger text button, and a sales chart that's slow to render. Every keystroke in the search box renders the whole page, chart included, and so does the button. The starter is that page. Restructure it so the chart doesn't render again when you type or change the text size.

Try to do it by changing where state lives, without memo, useMemo, or useCallback: the search text only matters to the search, and the text size wraps the chart but doesn't change what the chart shows. The tests only count chart renders, so memo would pass them too. The point of this exercise is the restructure.

Keep what the page does. Dashboard takes teas, a list of tea names, and an optional onChartRender, which it passes to SalesChart as onRender. It has a text box labelled Search teas. While the search has text (ignoring spaces at either end), the page shows how many teas contain it, ignoring case, as 2 matches, 1 match, or 0 matches, and a list labelled Suggestions with their names. With an empty search, neither shows.

The Larger text button has aria-pressed. The page's <main> has data-size="large" while it's pressed and data-size="normal" otherwise, and it wraps everything: the button, the search, and the chart. Changing the text size keeps the search text.

SalesChart stays as it is: it calls onRender every time it renders, which is how the tests count renders. What to fix: typing in the search box, or pressing Larger text, never renders SalesChart again. The tests render without Strict Mode. Export Dashboard and SalesChart by name; the default export, App, renders the dashboard and logs each chart render for the Preview.

<Dashboard teas={["Sencha", "Genmaicha", "Assam"]} onChartRender={() => console.log("chart")} />
 
// renders, after typing "cha":
<main data-size="normal">
  <button aria-pressed="false">Larger text</button>
  <label>Search teas <input value="cha" /></label>
  <p>2 matches</p>
  <ul aria-label="Suggestions"><li>Sencha</li><li>Genmaicha</li></ul>
  <figure>...the chart...</figure>
</main>

What the tests check

  1. shows matches and suggestions as you type
    render(<Dashboard teas={["Sencha", "Assam", "Genmaicha"]} />);
    expect(screen.queryByRole("list", { name: "Suggestions" })).not.toBeInTheDocument();
    await user.type(screen.getByLabelText("Search teas"), "CHA");
    expect(screen.getByText("2 matches")).toBeInTheDocument();
    const suggestions = within(screen.getByRole("list", { name: "Suggestions" })).getAllByRole("listitem");
    expect(suggestions.map((item) => item.textContent)).toEqual(["Sencha", "Genmaicha"]);
    
  2. says 1 match and 0 matches, and hides both for an empty search
    render(<Dashboard teas={["Sencha", "Assam", "Genmaicha"]} />);
    const search = screen.getByLabelText("Search teas");
    await user.type(search, "assam");
    expect(screen.getByText("1 match")).toBeInTheDocument();
    await user.type(search, "x");
    expect(screen.getByText("0 matches")).toBeInTheDocument();
    await user.clear(search);
    await user.type(search, "   ");
    expect(screen.queryByText(/match/)).not.toBeInTheDocument();
    expect(screen.queryByRole("list", { name: "Suggestions" })).not.toBeInTheDocument();
    
  3. Larger text toggles aria-pressed and the page's text size
    render(<Dashboard />);
    const button = screen.getByRole("button", { name: "Larger text" });
    expect(button).toHaveAttribute("aria-pressed", "false");
    expect(screen.getByRole("main")).toHaveAttribute("data-size", "normal");
    await user.click(button);
    expect(screen.getByRole("button", { name: "Larger text" })).toHaveAttribute("aria-pressed", "true");
    expect(screen.getByRole("main")).toHaveAttribute("data-size", "large");
    await user.click(screen.getByRole("button", { name: "Larger text" }));
    expect(screen.getByRole("main")).toHaveAttribute("data-size", "normal");
    
  4. the page wraps the button, the search, and the chart
    render(<Dashboard />);
    const main = screen.getByRole("main");
    expect(within(main).getByRole("button", { name: "Larger text" })).toBeInTheDocument();
    expect(within(main).getByLabelText("Search teas")).toBeInTheDocument();
    expect(within(main).getByText("Mon: 42 cups")).toBeInTheDocument();
    
  5. renders the chart once at first
    let chartRenders = 0;
    render(<Dashboard onChartRender={() => chartRenders++} />);
    expect(screen.getByText("Sat: 80 cups")).toBeInTheDocument();
    expect(chartRenders).toBe(1);
    
  6. typing in the search box doesn't render the chart again
    let chartRenders = 0;
    render(<Dashboard onChartRender={() => chartRenders++} />);
    await user.type(screen.getByLabelText("Search teas"), "oolong");
    expect(screen.getByText("1 match")).toBeInTheDocument();
    expect(chartRenders).toBe(1);
    
  7. pressing Larger text doesn't render the chart again
    let chartRenders = 0;
    render(<Dashboard onChartRender={() => chartRenders++} />);
    await user.click(screen.getByRole("button", { name: "Larger text" }));
    await user.click(screen.getByRole("button", { name: "Larger text" }));
    expect(screen.getByRole("main")).toHaveAttribute("data-size", "normal");
    expect(chartRenders).toBe(1);
    
  8. changing the text size keeps the search text
    render(<Dashboard teas={["Sencha", "Milk Oolong", "Rooibos"]} />);
    await user.type(screen.getByLabelText("Search teas"), "oo");
    await user.click(screen.getByRole("button", { name: "Larger text" }));
    expect(screen.getByLabelText("Search teas")).toHaveValue("oo");
    expect(screen.getByText("2 matches")).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).