adnansaleem

Rendering 20,000 rows in React without freezing the page

Why big tables are slow, the windowing maths that fixes it in about ten lines, where memoization fits, and how to measure the result.

By M. Adnan Saleem · · 3 min read

On a multi-currency payments platform I optimized large data tables with react-window virtualization and memoization, which cut render time by about 70% on datasets over 20,000 rows. The technique is small enough to write by hand, and writing it once makes the libraries easy to reason about. The code below is the same code that runs in the live demo on this site.

Why a big table is slow

The data is rarely the problem. Twenty thousand objects in memory is nothing. The cost is the DOM: every row is several elements that the browser has to create, style, lay out and paint, and React has to reconcile all of them on every update. A filter keystroke that touches 20,000 rows means 20,000 rows of work, even though the user can see about twelve.

Render only what is visible

Virtualization, also called windowing, keeps the full list in memory and puts only the visible slice in the DOM. With fixed-height rows it is arithmetic on the scroll position:

const ROW = 36;       // row height in px
const VIEW = 432;     // height of the scroll area in px
const OVERSCAN = 6;   // extra rows above and below, so fast scrolling shows no gaps

const first = Math.max(0, Math.floor(scrollTop / ROW) - OVERSCAN);
const last = Math.min(rows.length, Math.ceil((scrollTop + VIEW) / ROW) + OVERSCAN);
const visible = rows.slice(first, last);

Then give the browser an inner element as tall as the whole list, and shift the visible slice down to where it belongs:

<div style={{ height: VIEW, overflow: 'auto' }} onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>
  {/* Full height, so the scrollbar behaves as if every row were there */}
  <div style={{ height: rows.length * ROW, position: 'relative' }}>
    <div style={{ transform: `translateY(${first * ROW}px)` }}>
      {visible.map((row) => <Row key={row.id} row={row} />)}
    </div>
  </div>
</div>

In the demo that turns 20,000 rows of data into roughly two dozen rows in the DOM, whatever the scroll position.

Where memoization fits

Windowing fixes how many rows exist. Memoization fixes how often the survivors re-render. Two places matter:

const Row = React.memo(function Row({ row }) {
  return <div className="row">...</div>;
});

const filtered = useMemo(() => rows.filter(matches(query)), [rows, query]);
  • The row. Wrapped in React.memo with a stable key, a row that scrolls but does not change is not rendered again.
  • The derived list. Filtering and sorting 20,000 items on every scroll event is wasted work. useMemo recomputes only when the inputs change.

Memoization only helps if props are stable. An inline object or arrow function passed to every row defeats it.

Measure it

React finishing is not the same as the user seeing the result. To time an update through to paint, wait two animation frames after the state change:

const started = performance.now();
setQuery(next);
requestAnimationFrame(() =>
  requestAnimationFrame(() => console.log(performance.now() - started, 'ms to paint')),
);

Record the number with virtualization off, then on, with the same data and the same action. That before and after is the only claim worth putting in a pull request.

What to watch for

  • Variable row heights. The arithmetic above needs a fixed height. For rows that grow, use a library that measures them, and expect more complexity.
  • Find in page. The browser's search cannot see rows that are not in the DOM. Provide your own filter, as the demo does.
  • Accessibility. Tell assistive technology the real size with aria-rowcount and aria-rowindex, and make the scroll area focusable so it can be scrolled from the keyboard.
  • Production use. For real products use a maintained library such as react-window. It handles the edge cases; the principle is exactly the one above.

The project behind this

Read the case study

What I built, where, and the numbers that came out of it.

↑ ↓ to move · Enter to run · Esc to close