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>
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");
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");
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");
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");
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");
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");
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");
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");
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");
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");
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).
I'm here to help with Virtualized list. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.