Skip to content

ADR-0019 Virtual lists

Company standard virtual lists — TanStack Virtual, headless, for long lists, feeds, and large grids once pagination and CSS cannot do the job.

Decision
TanStack VirtualBuild the app
Status
Accepted
Date
Scope
Company-wide default for all new apps

Some screens show more rows than a browser should render at once: log viewers, audit trails, long pickers, chat and streamed AI output, large tables (ADR-0018). Every extra row is DOM nodes, layout work, and memory. Virtualization renders only the rows in the scroll viewport plus a small buffer.

It has costs, so it is not the first answer. The surrounding decisions:

  • React and SSR: TanStack Start (ADR-0002). A server has no layout, so a virtualizer cannot know how many rows fit
  • Components: shadcn/ui on Base UI with Tailwind v4 (ADR-0011); our markup, our tokens
  • Data: Query and URL state (ADR-0012). Virtualization renders data already in the browser; it does not shrink what is fetched
  • Tables: paginated on the server by default (ADR-0018); virtualization is the exception for very large loaded sets
  • Tests: component tests run in a real browser (ADR-0014), which is what a virtualizer needs — a simulated DOM has no layout to measure

What is open: which tool, and the rules that keep virtualized lists accessible.

Forces:

  • Often not needed. Pagination, server-side filtering, or plain CSS may solve the same problem with less risk
  • Accessibility. Rows outside the viewport are not in the DOM: browser find-in-page and assistive technology cannot reach them, and a focused row can be unmounted by scrolling
  • SSR. The first HTML should not be an empty list
  • Headless. The rows are shadcn/ui table rows, list items, or cards — the library must not dictate markup
  • TanStack Table fit. Large grids pair rows from table.getRowModel() with a virtualizer (ADR-0018)

Headless virtualizer: a hook that returns which items to render and where. @tanstack/react-virtual 3.14.x on @tanstack/virtual-core 3.19.x.

Pros

  • Headless: no markup or styles, so it works over shadcn/ui table rows, ul items, or cards; vertical, horizontal, and grid layouts (lanes, or one virtualizer per axis); useWindowVirtualizer for page-level scroll
  • The path TanStack Table documents: its virtualization guide, with row, column, and infinite-scroll examples, is built on it (ADR-0018)
  • Covers the hard parts: dynamic measurement (measureElement), scrollToIndex, rangeExtractor (keep chosen items rendered), initialRect and initialOffset for SSR, scroll restoration (takeSnapshot), and end-anchored feeds for chat and logs (anchorTo: "end", followOnAppend)
  • Same maintainers as Router, Query, Start, and Table; MIT; v3 stable since 2023-12-01; 12 releases between 2026-08-18 and 2026-10-16 (react-virtual 3.14.21 is the latest); the most used option (16.4 M npm downloads in the week to 2026-10-18, against 3.4 M for react-window)

Cons

  • Headless means accessibility is ours: it renders no roles, no ARIA, and no keyboard handling, and its docs do not cover any of the three
  • Sizes are estimates until measured; a poor estimateSize with dynamic heights causes scroll jumps and a drifting scrollbar. Its docs recommend estimating the largest size, and TanStack Table’s guide says to prefer fixed row sizes
  • On the server it renders no rows unless given initialRect (we checked on 3.14.21: 0 rows without it, 7 with a 200 px viewport and 35 px rows)
  • On React 19, scrolling can log a flushSync warning; the documented fix is useFlushSync: false, which its docs describe as trading some scroll accuracy for smoother updates
  • Two version numbers: the React adapter and the core are released separately, and each adapter version pins one exact core

Components for fixed-size and variable-size lists and grids. 2.4.1, MIT.

Pros

  • Small and focused: List and Grid, sizing without AutoSizer, default ARIA roles, and row components receive ariaAttributes
  • The best server-rendering story of the group: defaultHeight decides how many rows are server-rendered, with defaultScrollOffset and initialRowHeights added in 2.4.0
  • MIT; 3.4 M npm downloads in the week to 2026-10-18

Cons

  • Component-shaped (rowComponent plus rowProps), not hook-shaped; TanStack Table’s virtualization guide does not use it, so a grid means our own wiring
  • Dynamic row heights are “not as efficient as predetermined sizes” (its README)
  • v2 (2025-08-28) was a rewrite: v1 FixedSizeList snippets and tutorials no longer apply

Pros: variable-size items with no manual measuring; Virtuoso, GroupedVirtuoso (sticky group headers), VirtuosoGrid, TableVirtuoso (documented to work with TanStack Table), and masonry; core is MIT (react-virtuoso 4.18.19). Cons: its components render their own scroller and item wrappers, so control over markup goes through slots; the chat Message List component is under a commercial licence; a second way to virtualize next to the one TanStack Table documents.

Pros: zero-config, with automatic dynamic sizing; components start from about 3 kB gzipped and are tree-shakeable; VList, VGrid, keepMounted, SSR through ssrCount; MIT. Cons: pre-1.0 (0.53.5) — the API can still change under a company standard; smaller community (0.66 M npm downloads in the week to 2026-10-18); a real <table> element needs customization (its own comparison table says so), and TanStack Table’s docs do not cover it.

No JavaScript: the browser skips rendering and layout of off-screen subtrees.

Pros

  • Off-screen content stays in the DOM and the accessibility tree, and find-in-page, tab order, and focus keep working (MDN)
  • Baseline since September 2024 (Safari 18, Firefox 125); no dependency; SSR is trivial
  • web.dev’s demo went from 232 ms to 30 ms of initial rendering

Cons

  • It skips rendering work, not DOM nodes: every node stays in the DOM, so it will not carry tens of thousands of rows
  • Needs contain-intrinsic-size (auto <length>) per item, or skipped items collapse and the scrollbar jumps

6. No virtualization: pagination or “load more”

Section titled “6. No virtualization: pagination or “load more””

Pros: the simplest; every row is real DOM, so find-in-page, screen readers, and links work; works on the server unchanged. Cons: no continuous scrolling; poor for dense scanning (logs, feeds, spreadsheet-like grids).

TanStack Virtual is the company standard for virtualization, and the last step of a short ladder: paginate or filter first, then CSS, then Virtual. It is the standard for virtualized tables as well (ADR-0018).

Concretely:

  • The ladder. 1) If the user does not need every row on screen, paginate or filter on the server (ADR-0018, ADR-0012). 2) For long static, in-DOM content where find-in-page matters, use content-visibility: auto with contain-intrinsic-size: auto <px>. 3) When DOM size is the measured problem — thousands of interactive rows, streams, dense grids — use TanStack Virtual. Measure before climbing
  • Package: @tanstack/react-virtual only. It pins its @tanstack/virtual-core exactly; never install the core separately, and don’t expect the two version numbers to match
  • One shared hook per app (src/components/virtual/) wraps useVirtualizer, so house defaults live once. Components that render the rows stay presentational: they receive the virtual items and render. Keep overscan at its default of 1 until profiling shows blank rows while scrolling, then raise it. If React 19 logs the flushSync warning, set useFlushSync: false in that hook
  • One scroll element with a fixed height (overflow-auto; min-h-0 inside flex). Use useWindowVirtualizer only for page-level lists, with scrollMargin for anything above the list. Rows sit absolutely positioned in a relative container sized by getTotalSize(), as in TanStack’s examples
  • Stable keys, fixed heights. getItemKey returns the record id, never the index, and is memoized with useCallback. Prefer fixed row heights; add data-index and ref={virtualizer.measureElement} only for rows that really vary. In TanStack Table’s examples, dynamic row measurement is skipped in Firefox, which measures table borders differently
  • SSR: pass initialRect with the container’s fixed height so the first rows are in the HTML, and initialOffset when restoring a position. Where the height is not known up front, render a skeleton until useHydrated() (ADR-0013). Never ship a virtual list that renders as an empty server HTML
  • Data: a virtual list over remote data loads pages with useInfiniteQuery (ADR-0012) and fetches the next page when the last virtual index nears the loaded count. Sorting and filtering stay on the server; never mix them with client-side processing over loaded pages (ADR-0018). Chat and streamed AI output use the end-anchored mode (anchorTo: "end", followOnAppend; see ADR-0025)
  • With TanStack Table: count is table.getRowModel().rows.length; render virtualizer.getVirtualItems(), never rows.map(), and read rows[virtualRow.index]. Tables follow TanStack’s layout: display: grid on table and thead, display: flex on tr, cells sized by column.getSize(), and a sticky thead. Our copy of shadcn/ui’s Table wraps the <table> in an overflow-x-auto div, which is itself a scroll container: for virtualized tables, give that wrapper the fixed height and use it as the scroll element (a containerRef and containerClassName on our Table), or the sticky header is tied to the wrapper instead of our scroller
  • Semantics are ours:
    • Tables stay real tables. Browsers have kept table semantics under display: grid since 2023 (display: contents is the exception); test with a screen reader anyway. Put aria-rowcount (header included) on the table and aria-rowindex on every rendered row, header = 1, because rows are missing from the DOM
    • Lists are ul/li (or role="list" / "listitem") with aria-setsize and aria-posinset on each item, as ARIA requires when only part of a set is in the DOM
  • Focus survives scrolling. A custom rangeExtractor adds the focused or active index to defaultRangeExtractor’s result, so that row never unmounts. Without it, scrolling away drops focus, and a roving tabindex="0" that unmounts leaves the list with no tab stop
  • Keyboard: one tab stop per list. Use roving tabindex (the browser scrolls the focused element into view) or aria-activedescendant (the active item must stay a DOM descendant, hence the previous rule). Arrow, Page Up/Down, Home, and End call scrollToIndex(), then focus the row once it mounts; call cancelScroll() when the user wheels, touches, or presses a key mid-scroll. A read-only table keeps native semantics (all focusable controls are tab stops); role="grid" and its arrow-key model only for interactive or editable grids (ARIA Authoring Practices). Row-level shortcuts go through ADR-0024
  • No find-in-page. Off-screen rows can’t be found with Ctrl+F; give every virtualized list a search or filter control
  • Tests: browser-mode component tests (ADR-0014) assert that the expected rows render, that scrollToIndex mounts its target, and that focus survives a scroll; axe runs on pages with virtual lists

The deciding factors: headless, so our markup and tokens stay; the library TanStack Table documents for large grids; one release train with Router, Query, and Table; and the hard parts (dynamic sizes, SSR hooks, end-anchored feeds) already built. We accept that accessibility, focus, and keyboard handling are ours, and that virtualization is a last resort rather than a default.

Easier:

  • One virtualization API for lists, tables, grids, and feeds, with markup that stays shadcn/ui
  • Large tables pair with Table’s documented pattern; chat and log views use a built-in end-anchored mode
  • A shared hook and shared row components make the accessibility rules (counts, indexes, focus) a default, not a per-screen chore

Harder:

  • Accessibility is ours: aria-rowcount / aria-rowindex or aria-setsize / aria-posinset, focus kept mounted, and keyboard handling must be built once and reviewed. Nothing here is free from the library
  • No browser find-in-page, and virtualized rows are invisible to assistive technology until they scroll in; a search or filter control is the substitute
  • Layout is more fragile than a plain list: a fixed-height scroller, absolute positioning, a custom grid layout for tables, and the Table wrapper change
  • Dynamic heights cost scroll accuracy; fixed heights cost design freedom
  • Without initialRect, the first HTML is empty; each virtual list needs its SSR story
  • Tests need a real browser and real layout; react-virtual and virtual-core upgrade together, with two version numbers

Given up: react-window’s built-in ARIA roles and tidy SSR props, react-virtuoso’s and virtua’s zero-config dynamic sizing, and Virtuoso’s ready-made grouped, grid, and table components. Accepted — one headless API that TanStack Table documents matters more for a company default; a product that needs a chat-grade message list can weigh a commercial component in its own ADR.