Virtualization
A long list and table that mount one window of rows
1–9 in view · 13 mounted of 1000
Using TanStack Virtual.
Usage
import { VirtualList } from "@gpuixui/data";
<VirtualList height={320} aria-label="Files">
{rows.map((row) => (
<div key={row.id} style={{ height: 32 }}>
<text>{row.label}</text>
</div>
))}
</VirtualList>;API
VirtualList
| Prop | Type | Default | Consequence |
|---|---|---|---|
height | number | caller's layout | a definite viewport height |
children | ReactNode | — | the rows |
followTail | boolean | false | keeps the list at its trailing edge as rows arrive |
alignment | "top" | "bottom" | top | where the content sits when it does not fill the viewport |
itemCount | number | children mode | switches to the windowed mode |
estimatedItemHeight | number | — | required with itemCount; a height the runtime cannot lay out is read as the shipped row's own (37) |
windowStart | number | 0 | the first rendered slice's index |
onVisibleRange | (range: VisibleRange) => void | — | the built window |
overdraw | number | — | rows built beyond the viewport; a count the runtime cannot read is the default (4), a negative is 0 |
className | string | — | joined after the list's own (empty) recipe |
ref | Ref<PublicInstance> | — | the list's instance, for scrollToItem |
VirtualTable
| Prop | Type | Default | Consequence |
|---|---|---|---|
count | number | — | the logical row count; a fraction, a negative or an unreadable count is the whole, non-negative rows the table holds (0 paints empty) |
height | number | measured viewport | a definite table height; one the layout cannot paint is no height, so the table fills its column |
rowHeight | number | — | every row must paint exactly this height; a height the window cannot divide is read as the shipped row's own (37) |
header | ReactNode | — | the header row's cells |
row | (index: number) => ReactNode | — | paints one absolute row index |
empty | ReactNode | No rows | painted inside a TableEmpty row when count is 0; a string paints as its own text element |
caption | ReactNode | — | the line below the frame; a string paints as its own text element |
overdraw | number | 4 | rows mounted beyond the viewport on each side; a count the window cannot read is 4, a negative is 0 |
reveal | VirtualReveal | — | { index }; the window moves, then the body scrolls |
onVisibleRange | (range: VisibleRange) => void | — | the runtime's lagging report |
className | string | — | joined after the recipe |
VirtualTableBody
| Prop | Type | Default | Consequence |
|---|---|---|---|
count | number | — | the logical row count; read the same way VirtualTable reads it |
rowHeight | number | — | the painted height of one row; a height the window cannot divide is 37 |
renderRow | (index: number) => ReactNode | — | paints one absolute row index |
height | number | the measured body | a definite body height; omit it inside a bounded Table |
overdraw | number | 4 | rows mounted beyond the viewport on each side |
reveal | VirtualReveal | — | { index }; the window moves, then the body scrolls |
onVisibleRange | (range: VisibleRange) => void | — | the runtime's lagging report |
instanceRef | Ref<PublicInstance> | — | the list's instance, for a consumer that scrolls it itself |
testId | string | — | the body's locator |
The height a window needs
A windowed surface computes its range from a viewport height and has no natural
height of its own: a content-sized box would report a height, mount more rows
and grow again. Give it a definite height when you can.
A height the layout cannot paint — NaN, an infinity, a negative, the value a
computed pane measure has before the first layout — reads as no height, so
the surface takes the share of its pane that the layout hands out and windows
against the height that box painted:
<div style={{ display: "flex", flexDirection: "column", height: 320 }}>
<VirtualTree aria-label="Files" rows={tree.rows} labelOf={labelOf} />
</div>VirtualTablefills its container and windows against the measured viewport frame, exactly as it does with a height;VirtualTreemeasures the column it fills and draws the list at that height. The runtime's<virtual-list>paints no surface of its own, so it reports noonLayout(measured on 0.11.0) — the list cannot measure itself.
The pane must bound the column's height. A surface with no viewport at all — an auto-height or non-flex parent — has nothing to fill and paints no rows, which is the same state as a window with no bound.
In a 320px pane both paint the window they paint at height={320}: 8 table rows
and 10 tree rows in behaviour/paintable-height.test.tsx.
Paging versus windowing
The two bound paint differently, and a grid usually wants one of them:
- a window mounts the rows near the viewport and follows the scroll — the reader can reach any row, and the set is never sliced;
- a page slices the filtered and sorted set at a page size and paints one slice — the reader moves the slice, and every row on it is mounted.
Paging is useTable's, and it runs on the same @tanstack/table-core
pagination row model the sort and the filter do, so the page is a slice of what
the core kept instead of a second filter:
| Member | Type | Default | Consequence |
|---|---|---|---|
pageSize (option) | number | ALL_ROWS | how many rows one page paints, read at mount (setPageSize after it); a value the grid cannot lay out is every row |
pageIndex | number | 0 | the page the grid paints, clamped to the pages the kept set holds |
pageCount | number | — | how many pages the kept set holds; never zero, so an empty grid is one page |
pageSize | number | ALL_ROWS | the size the grid runs |
setPageIndex | (pageIndex: number) => void | — | move to a page, clamped |
setPageSize | (pageSize: number) => void | — | change the size, keeping the reader's top row in view |
nextPage / previousPage | () => void | — | move one page, clamped at the ends |
rows / keys | the page | — | the painted slice, and the order a Shift range spans |
kept | ReadonlyArray<Row> | — | the rows the tree and the query keep, before sorting or a page |
selectedCount | number | — | chosen records in the kept set, not on the page |
allSelected / someSelected | boolean | — | the header box's state over the painted page |
Four rules are measured, not assumed:
- a filter, a search or a new sort returns to the first page — the records
the reader was reading are not where the index says they are once the set
changes (
setQuery,pressSort,toggleSortandsetSortall reset it); a new predicate tree does the same, and it arrives as a controlled prop rather than a gesture, so the index is stamped with the tree it was measured against and a replacement paints page one in the same commit; - a page index past the set clamps to its last page — a saved workspace reopens with the page it was on, and a set that has since narrowed paints its last page instead of a blank one past the end — and the arrows and a page size change measure from the page it paints, so a saved index past the set cannot strand Previous on the page already shown or carry a new size past the reader's top row;
- a page size change keeps the reader's top row in view, so doubling the size shows the same records from the same first row;
- the selection is the kept set's, not the page's — a record chosen on page one is still chosen (and still saved) while page two paints, while the header's box covers only what it paints.
The page is part of the record a grid saves: save() writes pagination, and
readGridState(stored) reads it back, so a workspace reopens on the page the
reader left. pageSize of every row is Infinity, which JSON writes as null
and the reader takes as every row again. A store-driven grid builds the core
itself and passes onPaginationChange; ALL_ROWS, DEFAULT_PAGINATION,
pageIndexOf, pageSizeOf, pageCountOf, clampPageIndex, withPageSize and
readPagination are exported for the arithmetic it needs.
const table = useTable({ rows, keyOf: (row) => row.id, columns, pageSize: 20 });
<Button
disabled={table.pageIndex >= table.pageCount - 1}
onActivate={table.nextPage}
>
Next
</Button>;
// `Page ${table.pageIndex + 1} of ${table.pageCount}` · `${table.rows.length} rows`pagination/logic.test.ts holds the arithmetic, behaviour/table-pagination
the page the shipped controller paints, and behaviour/pagination-example the
screen in examples/table-pagination.example.tsx. A grid that must never slice
its set uses the windowed surfaces above instead.
Edit this page on GitHubpackages/data/docs/virtualization.mdx