Skip to content

Server-side Paging

Offset mode requests only the pages near the viewport. Placeholders fill the rest of the scroll height while pages are pending, so total scroll size stays correct from the first render.

The fetch function receives { offset, limit, params, signal } and returns { rows, totalCount }.

  • remoteModel()
  • defaultOffsetViewportHandler — built-in handler for offset/limit backends
  • AbortSignal-aware fetch functions
import { useState } from 'react'

import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader } from '@/components/ui/data-table'
import { defaultOffsetViewportHandler, remoteModel } from '@virtuoso.dev/data-table'

import { fetchProducts, placeholder } from './api'

import type { Product, Query } from './api'

export default function App() {
  const [category, setCategory] = useState('all')

  const [model] = useState(() =>
    remoteModel<Product, Query>({
      actions: {
        filter: {
          handler: ({ params, payload }) => ({ ...params, category: payload as string }),
        },
      },
      fetch: fetchProducts,
      initialParams: { category: 'all' },
      onViewportChange: defaultOffsetViewportHandler,
      pageSize: 40,
      placeholder,
    })
  )

  return (
    <div className="space-y-4">
      <select
        className="h-9 rounded-md border bg-background px-3 text-sm"
        onChange={(event) => {
          const nextCategory = event.target.value
          setCategory(nextCategory)
          model.send({ action: 'filter', payload: nextCategory })
        }}
        value={category}
      >
        <option value="all">All categories</option>
        <option value="Office">Office</option>
        <option value="Peripherals">Peripherals</option>
        <option value="Audio">Audio</option>
      </select>

      <DataTable className="rounded-xl" model={model} style={{ height: 420 }}>
        <DataTableColumn field="name">
          <DataTableColumnHeader>Product</DataTableColumnHeader>
          <DataTableCell className="font-medium">{({ row }) => row.data.name}</DataTableCell>
        </DataTableColumn>
        <DataTableColumn field="category">
          <DataTableColumnHeader>Category</DataTableColumnHeader>
          <DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
        <DataTableColumn field="price">
          <DataTableColumnHeader className="justify-end">Price</DataTableColumnHeader>
          <DataTableCell className="text-right tabular-nums">{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
      </DataTable>
    </div>
  )
}

Offset mode needs the backend to answer count-aware slice queries efficiently. For cursor APIs, search-after pagination, or feeds that don’t expose a total count, see the Cursor Load More example.