Skip to content

Infinite scrolling ​

With infinite scrolling the grid loads rows from the server block by block while the user scrolls – no pagination bar, the scrollbar covers all rows. Sorting and filtering are done by the server, like with server side data.

vue
<template>
  <div class="demo">
    <Datagrid :columns="columns" :options="options" />
    <div class="demo-log">
      <div v-for="(line, index) in log" :key="index">{{ line }}</div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import { Datagrid, type ColumnConfig, type DataGetterParams, type GridOptions } from "@datagrid/vue-ui";
import { createEmployees, type Employee } from "./data";

const log = ref<Array<string>>([]);

// a fake REST api with 100.000 rows and 300ms latency
const database = createEmployees(100_000);

async function fetchEmployees(params: DataGetterParams) {
  const { startRow = 0, endRow = 100 } = params;
  const sort = params.sort.map((s) => `${s.field} ${s.asc ? "asc" : "desc"}`).join(", ") || "none";
  log.value = [`GET rows ${startRow}–${endRow - 1}, sort: ${sort}, filters: ${params.filter.length}`, ...log.value].slice(0, 20);

  // in a real application:
  // const response = await fetch(`/api/employees?start=${startRow}&end=${endRow}&...`, { signal: params.signal });
  // return response.json(); // { data: [...], total: 12345 }
  await new Promise((resolve) => setTimeout(resolve, 300));

  let result = database;
  for (const { field, value } of params.filter) {
    const text = String(value).toLowerCase();
    result = result.filter((row) => String(row[field as keyof Employee]).toLowerCase().includes(text));
  }
  for (const { field, asc } of [...params.sort].reverse()) {
    const key = field as keyof Employee;
    result = [...result].sort((a, b) => (a[key] > b[key] ? 1 : a[key] < b[key] ? -1 : 0) * (asc ? 1 : -1));
  }

  return { data: result.slice(startRow, endRow), total: result.length };
}

const columns: Array<ColumnConfig<Employee>> = [
  { field: "id", text: "ID", type: "number", width: 90, sortable: true },
  { field: "firstName", text: "First name", filterable: true },
  { field: "lastName", text: "Last name", filterable: true },
  { field: "city", text: "City", filterable: true },
  { field: "salary", text: "Salary", type: "number", sortable: true },
];

const options: GridOptions<Employee> = {
  dataGetter: fetchEmployees,
  infiniteScroll: { blockSize: 100 },
  rowHeight: 32,
  statusBar: { items: ["rows"] },
  defaultColumn: { flex: 1 },
};
</script>
tsx
import { useMemo, useState } from "react";
import { Datagrid, type ColumnConfig, type DataGetterParams, type GridOptions } from "@datagrid/react-ui";
import { createEmployees, type Employee } from "../data";

// a fake REST api with 100.000 rows and 300ms latency
const database = createEmployees(100_000);

const columns: Array<ColumnConfig<Employee>> = [
  { field: "id", text: "ID", type: "number", width: 90, sortable: true },
  { field: "firstName", text: "First name", filterable: true },
  { field: "lastName", text: "Last name", filterable: true },
  { field: "city", text: "City", filterable: true },
  { field: "salary", text: "Salary", type: "number", sortable: true },
];

export default function InfiniteScroll() {
  const [log, setLog] = useState<Array<string>>([]);

  const options = useMemo((): GridOptions<Employee> => {
    async function fetchEmployees(params: DataGetterParams) {
      const { startRow = 0, endRow = 100 } = params;
      const sort = params.sort.map((s) => `${s.field} ${s.asc ? "asc" : "desc"}`).join(", ") || "none";
      setLog((log) => [`GET rows ${startRow}–${endRow - 1}, sort: ${sort}, filters: ${params.filter.length}`, ...log].slice(0, 20));

      // in a real application:
      // const response = await fetch(`/api/employees?start=${startRow}&end=${endRow}&...`, { signal: params.signal });
      // return response.json(); // { data: [...], total: 12345 }
      await new Promise((resolve) => setTimeout(resolve, 300));

      let result = database;
      for (const { field, value } of params.filter) {
        const text = String(value).toLowerCase();
        result = result.filter((row) => String(row[field as keyof Employee]).toLowerCase().includes(text));
      }
      for (const { field, asc } of [...params.sort].reverse()) {
        const key = field as keyof Employee;
        result = [...result].sort((a, b) => (a[key] > b[key] ? 1 : a[key] < b[key] ? -1 : 0) * (asc ? 1 : -1));
      }

      return { data: result.slice(startRow, endRow), total: result.length };
    }

    return {
      dataGetter: fetchEmployees,
      infiniteScroll: { blockSize: 100 },
      rowHeight: 32,
      statusBar: { items: ["rows"] },
      defaultColumn: { flex: 1 },
    };
  }, []);

  return (
    <div className="demo">
      <Datagrid columns={columns} options={options} />
      <div className="demo-log">
        {log.map((line, index) => (
          <div key={index}>{line}</div>
        ))}
      </div>
    </div>
  );
}

Drag the scrollbar to the middle: only the blocks of the visible rows are requested. Sort by salary or filter a column and watch the requests below the grid.

Setup ​

Set infiniteScroll together with a dataGetter (and no rows):

ts
const options: GridOptions<Employee> = {
  dataGetter: fetchEmployees,
  infiniteScroll: true,               // blocks of 100 rows
  // infiniteScroll: { blockSize: 200 },
  rowHeight: 32,
};
OptionDescription
infiniteScrolltrue or { blockSize } – enables infinite scrolling, requires dataGetter
infiniteScroll.blockSizenumber of rows loaded with one request, default 100

A fixed rowHeight is recommended: rows which are not loaded yet can't be measured, so the scrollbar is more precise and doesn't jump.

The data getter ​

The dataGetter receives the same parameters as for server side data, plus the requested range:

PropertyDescription
startRowindex of the first row to load
endRowindex after the last row to load (exclusive), startRow + blockSize
sort, filter, globalFiltercurrent sort and filters, apply them on the server
paginationalways undefined

It returns the rows of the block and optionally the total number of rows matching the filters:

ts
async function fetchEmployees({ startRow, endRow, sort, filter, signal }: DataGetterParams) {
  const response = await fetch(`/api/employees?start=${startRow}&end=${endRow}&sort=${toSortParam(sort)}`, { signal });
  return response.json(); // { data: [...], total: 100000 }
}

Known total ​

If total is returned, the scrollbar covers all rows from the beginning, and the user can jump to any position. Only the blocks of the visible rows (including the buffer rows) are loaded, several blocks are loaded in parallel.

Unknown total ​

If the server can't count the rows cheaply, omit total. The grid then assumes there is one more block as long as full blocks are returned. The row count grows by one block whenever the user scrolls to the end. As soon as a block contains fewer rows than blockSize, the end is reached and the row count is exact.

ts
return { data: rows }; // no total

Loading rows ​

Rows which are not loaded yet are rendered as placeholder rows with an animated skeleton in each cell. They get the class ut-row-loading, so the placeholders can be styled:

css
.ut-row-loading .ut-cell::after {
  background: #eee;
  animation: none;
}

While a block is loading, the loading bar at the top of the grid is shown. The data of placeholder rows is undefined (and row.loading is true), so functions like rowClass, rowStyle or cellStyle and custom cell renderers must handle rows without data (row.data?.name).

Sorting and filtering ​

When the sort, a column filter or the quick filter changes, all loaded blocks are discarded: every row becomes a placeholder again and the first block and the blocks of the visible rows are requested with the new parameters. The scroll position is kept.

Responses of requests started before the change are ignored, so a slow response for the old sort order can never overwrite newer rows. Use params.signal to cancel a running fetch.

Call api.refresh() to reload all blocks, e.g. after the data was changed on the server.

Notes ​

  • Rows are identified by their position, rowId is not used. The selection refers to row positions, so it is not meaningful after the sort or filters changed.
  • Grouping, tree data and aggregations need all rows and are not supported with infinite scrolling. Use pagination with a server side data getter for grouped data or let the server return group rows.
  • The status bar item rows shows the (known or estimated) number of rows.

Released under the ISC License.