Skip to the code
cf.

Virtualized list

The tea shop's order history has 10,000 rows. Putting 10,000 <li> elements in the page makes the first render slow and scrolling heavy, though only about ten fit on screen at once. Build VirtualList: a scroll box that renders only the rows you can see, plus a few around them so fast scrolling doesn't show gaps.

Props: items, any array; rowHeight, the height of every row in pixels (30 by default); height, the height of the box in pixels (300 by default); overscan, how many extra rows to render on each side (3 by default); renderRow(item, index), which returns a row's content (by default the item as text); and label, the box's accessible name (Rows by default).

Markup: the box is a <div role="region"> with aria-label set to label, tabIndex={0} so keyboard users can scroll it, and the style height (in px) and overflowY: "auto". Inside it is one <ul> with position: "relative" and a style height of items.length * rowHeight px: it's the spacer that makes the scroll bar the right size. Each rendered row is an <li> with position: "absolute", top of index * rowHeight px, height of rowHeight px, aria-setsize of items.length, and aria-posinset of index + 1.

Which rows: keep scrollTop in state, starting at 0, and update it from the box's scroll event (event.currentTarget.scrollTop). A row is in view if any part of it is inside the box, so the first is the row at scrollTop and the last is the row at the box's bottom pixel, scrollTop + height - 1. Render those, plus overscan rows on each side, never before the first row or past the last. Call renderRow only for the rows you render.

The tests have no layout, so don't measure anything: everything comes from the props. They scroll by setting box.scrollTop and firing a scroll event on the box. Export VirtualList by name. The default export, App, shows it with 10,000 orders for the Preview.

<VirtualList items={orders} rowHeight={30} height={300} overscan={2} label="Orders" renderRow={(order) => "Order " + order.id} />
 
// scrolled to scrollTop 45: indexes 1 to 11 are in view, so with overscan 2 it renders indexes 0 to 13
<div role="region" aria-label="Orders" tabindex="0" style="height: 300px; overflow-y: auto;">
  <ul style="position: relative; height: 300000px;">
    <li aria-setsize="10000" aria-posinset="1" style="position: absolute; top: 0px; height: 30px;">Order 1</li>
    ...
    <li aria-setsize="10000" aria-posinset="14" style="position: absolute; top: 390px; height: 30px;">Order 14</li>
  </ul>
</div>

What the tests check

  1. renders only the rows that fit at first
    const items = Array.from({ length: 10000 }, (_, i) => "Order " + (i + 1));
    render(<VirtualList items={items} rowHeight={30} height={300} overscan={0} label="Orders" />);
    const rows = screen.getAllByRole("listitem");
    expect(rows).toHaveLength(10);
    expect(rows[0]).toHaveTextContent("Order 1");
    expect(rows[9]).toHaveTextContent("Order 10");
    
  2. adds overscan rows below, but none before the first row
    const items = Array.from({ length: 10000 }, (_, i) => "Order " + (i + 1));
    render(<VirtualList items={items} label="Orders" />);
    const rows = screen.getAllByRole("listitem");
    expect(rows).toHaveLength(13);
    expect(rows[0]).toHaveTextContent("Order 1");
    expect(rows[12]).toHaveTextContent("Order 13");
    
  3. sizes the box and the spacer, and puts each row in its place
    const items = Array.from({ length: 10000 }, (_, i) => "Order " + (i + 1));
    render(<VirtualList items={items} rowHeight={30} height={300} label="Orders" />);
    const box = screen.getByRole("region", { name: "Orders" });
    expect(box.style.height).toBe("300px");
    expect(box.style.overflowY).toBe("auto");
    expect(box).toHaveAttribute("tabindex", "0");
    expect(screen.getByRole("list").style.height).toBe("300000px");
    const third = screen.getAllByRole("listitem")[2];
    expect(third.style.position).toBe("absolute");
    expect(third.style.top).toBe("60px");
    expect(third.style.height).toBe("30px");
    
  4. shows the rows in view after a scroll
    const items = Array.from({ length: 10000 }, (_, i) => "Order " + (i + 1));
    render(<VirtualList items={items} rowHeight={30} height={300} overscan={0} label="Orders" />);
    const box = screen.getByRole("region", { name: "Orders" });
    box.scrollTop = 3000;
    fireEvent.scroll(box);
    const rows = screen.getAllByRole("listitem");
    expect(rows).toHaveLength(10);
    expect(rows[0]).toHaveTextContent("Order 101");
    expect(rows[0].style.top).toBe("3000px");
    expect(rows[9]).toHaveTextContent("Order 110");
    
  5. counts rows that are only partly in view
    const items = Array.from({ length: 10000 }, (_, i) => "Order " + (i + 1));
    render(<VirtualList items={items} rowHeight={30} height={300} overscan={0} label="Orders" />);
    const box = screen.getByRole("region", { name: "Orders" });
    box.scrollTop = 45;
    fireEvent.scroll(box);
    const rows = screen.getAllByRole("listitem");
    expect(rows).toHaveLength(11);
    expect(rows[0]).toHaveTextContent("Order 2");
    expect(rows[10]).toHaveTextContent("Order 12");
    
  6. adds overscan rows on both sides after a scroll
    const items = Array.from({ length: 10000 }, (_, i) => "Order " + (i + 1));
    render(<VirtualList items={items} rowHeight={30} height={300} overscan={2} label="Orders" />);
    const box = screen.getByRole("region", { name: "Orders" });
    box.scrollTop = 3000;
    fireEvent.scroll(box);
    const rows = screen.getAllByRole("listitem");
    expect(rows).toHaveLength(14);
    expect(rows[0]).toHaveTextContent("Order 99");
    expect(rows[13]).toHaveTextContent("Order 112");
    
  7. stops at the last row at the bottom
    const items = Array.from({ length: 10000 }, (_, i) => "Order " + (i + 1));
    render(<VirtualList items={items} rowHeight={30} height={300} overscan={3} label="Orders" />);
    const box = screen.getByRole("region", { name: "Orders" });
    box.scrollTop = 299700;
    fireEvent.scroll(box);
    const rows = screen.getAllByRole("listitem");
    expect(rows).toHaveLength(13);
    expect(rows[0]).toHaveTextContent("Order 9988");
    expect(rows[12]).toHaveTextContent("Order 10000");
    
  8. gives each row aria-setsize and aria-posinset
    const items = Array.from({ length: 10000 }, (_, i) => "Order " + (i + 1));
    render(<VirtualList items={items} overscan={0} label="Orders" />);
    const box = screen.getByRole("region", { name: "Orders" });
    box.scrollTop = 600;
    fireEvent.scroll(box);
    const first = screen.getAllByRole("listitem")[0];
    expect(first).toHaveAttribute("aria-setsize", "10000");
    expect(first).toHaveAttribute("aria-posinset", "21");
    
  9. calls renderRow only for the rows it renders, with the index
    const items = Array.from({ length: 10000 }, (_, i) => "Order " + (i + 1));
    const renderRow = spy((item, index) => index + ": " + item);
    render(<VirtualList items={items} renderRow={renderRow} label="Orders" />);
    expect(renderRow.callCount).toBe(13);
    expect(renderRow.calls[0].args).toEqual(["Order 1", 0]);
    expect(screen.getAllByRole("listitem")[0]).toHaveTextContent("0: Order 1");
    
  10. handles a short list and an empty one
    const { rerender } = render(<VirtualList items={["Sencha", "Assam", "Rooibos", "Oolong"]} label="Orders" />);
    expect(screen.getAllByRole("listitem")).toHaveLength(4);
    expect(screen.getByRole("list").style.height).toBe("120px");
    rerender(<VirtualList items={[]} label="Orders" />);
    expect(screen.queryByRole("listitem")).not.toBeInTheDocument();
    expect(screen.getByRole("list").style.height).toBe("0px");
    

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