Server-side infinite scroll

One long list, a million rows on the server, and a scrollbar that covers all of them: the grid asks for the block under the viewport, shows placeholder rows for what has not arrived, and forgets blocks it scrolled far past. createServerDataSource does this in infinite mode over the same getRows contract as page mode, and the grid wires itself to it through one prop.

Open the live example: Server-Side Row Model: paged and infinite (Server-Side Data)

Quick start

<script lang="ts">
  import { SvGrid, createServerDataSource, type ServerDataSource } from '@svgrid/grid'

  const source: ServerDataSource<Row> = {
    async getRows({ startRow, endRow, sortModel, filterModel }) {
      const res = await fetch('/api/rows', { method: 'POST', body: JSON.stringify({ startRow, endRow, sortModel, filterModel }) })
      const { rows, total } = await res.json()
      return { rows, rowCount: total }
    },
  }

  const ctl = createServerDataSource(source, { mode: 'infinite', blockSize: 100, maxBlocksInCache: 10 })
</script>

<SvGrid rowModel={ctl} {columns} sortable filterable />

rowModel={ctl} is the whole wiring. The controller supplies the rows, the loading flag, the external sort and filter hooks, the visible range and the placeholder rows; the grid reads each one from it. A prop written on the grid still wins, so adopting the model is never all-or-nothing.

How a block loads

The grid reports the row indices on screen; the controller turns them into block numbers (blockSize rows per block) and fetches the blocks it does not have. A request is getRows({ startRow, endRow, ... }) with the block's bounds, so the backend does the same OFFSET / LIMIT it does for a page.

Until a block lands, its rows are placeholders: the grid draws a shimmer in every cell and marks the row aria-busy. Placeholder rows cannot be selected, edited or navigated into; there is nothing there yet. A block whose request rejected keeps its rows as a tinted band with one full-width "could not load" message and a Retry button on the first row in view, which calls retryLoads(). A first block that fails before anything is known about the list stays initialRowCount rows tall, so the message is not followed by a hundred empty slots.

The options mirror what a block cache needs, and nothing more:

Option Default Does
mode: 'infinite' 'page' One scrollable list instead of a pager.
blockSize 100 Rows per request.
maxBlocksInCache unlimited Loaded blocks to keep; the least recently seen is evicted past this.
maxConcurrentRequests 2 Requests in flight at once; the rest queue.
blockLoadDebounceMs 0 Wait for the scroll to settle this long before fetching.
initialRowCount 1 Rows to claim before anything has loaded, so there is a scrollbar.

An evicted block reloads when scrolled back to, at the cost of one request; set maxBlocksInCache when the rows are wide enough that holding all of them would matter.

When the count is unknown

Counting a filtered million rows can cost more than fetching a block of them. Return rowCount: -1 and the controller runs without a total: the scrollbar grows one block past the highest loaded block, and the end is wherever the first short block arrives. state.lastRowKnown says which state it is in.

Refresh, purge, retry

Method Does
refresh() Re-fetch the loaded blocks in place; keeps the row count and the scroll position.
purge() Drop every cached block and reload from the current viewport.
retryLoads() Re-fetch the blocks that failed.
getCacheState() Every block with its status: loading, loaded, failed.
setViewport(a, b) Tell the controller what is on screen. The rowModel prop does this for you.

Sorting and filtering go through setSort / setFilter as in page mode; both drop the cache, because the row at index 200 is a different row once the order changes.

Writes

createRow, updateRow and deleteRow work as in page mode. After a write the controller re-reads the blocks it holds, in place, so the list keeps its length and its scroll. With optimistic on (and a getRowId), an update patches the cached row at once and a delete removes it, both rolled back if the server rejects.

Wiring without the prop

rowModel is a convenience. Every seam it fills is a plain prop you can wire yourself, which is also how a model of your own would plug in:

<SvGrid
  data={view.rows}
  loading={view.loading}
  externalSort externalFilter
  onSortingChange={(s) => ctl.setSort(s)}
  onFiltersChange={(f) => ctl.setFilter(f)}
  onVisibleRangeChange={(r) => ctl.setViewport(r.startIndex, r.endIndex)}
  rowPlaceholder={rowPlaceholderState}
  onRetryRow={() => ctl.retryLoads()}
/>

rowPlaceholderState (exported from @svgrid/grid) reads the marker the controller puts on its placeholder rows; a model that makes its own can mark them with createRowPlaceholder.

What stays free, and what does not

Flat infinite scroll, sorting, filtering, the global search and row CRUD are the free row model. Grouping on the server with lazy expand per level, tree data, pivot, transactions on loaded blocks, and a selection that reaches rows the grid never loaded are the Enterprise Server-Side Row Model - the same contract, the same rowModel prop, more of the request filled in.

More examples

Server-side infinite scroll, by hand

The same block-by-block loading without the controller: a 100k-event audit log behind a mock API, sparse chunked load on scroll, sort and filter pushed to the server. The manual pattern, for a backend the contract does not fit.

Open the live example: Server-side infinite scroll (Server-Side Data)

Live 10M-row dashboard

A ten-million-transaction stream behind a mock API: server-side paging, sort and filter with live deltas merged into the loaded page.

Open the live example: Live 10M-row dashboard (Server-Side Data)

See also

Live examples

  • Server-Side Row Model: paged and infinite - The free half of the row model: implement one async getRows({ startRow, endRow, sortModel, filterModel }) and createServerDataSource owns the sort/filter/page lifecycle and races stale responses away. Paged, the grid holds one 50-row page; infinite, a block cache under the scrollbar with placeholders, retry and an LRU. Here a 100,000-row in-memory server behind 250ms latency. Grouping, tree, pivot and transactions on top of the same contract are the Enterprise row model (demo 467).
  • Server-side infinite scroll - 100k-event audit log behind a mock API. Sparse chunked load on scroll; sort + filter + search pushed to the server.
  • Live 10M-row dashboard - 10,000,000-transaction stream behind a mock API: server-side paging, sort, filter, a 1-second live feed, and inline SVG throughput/distribution charts.

Related articles