Skip to content

Server data and infinite scroll

When the rows live on a server, give the grid a source instead of rows. Three sources, from @dzgrid/core:

Source Entry What the user gets
sourceRows(data) @dzgrid/core/server pages (with the pager footer), or every row the server’s filter keeps
infiniteRows(data) @dzgrid/core/infinite one long list, loaded in blocks while scrolling; blocks far away are let go
serverSideRows(data, { by }) @dzgrid/core/server-side groups computed by the server, each group’s rows loaded in blocks

Each takes the same DataSource: a fetch(request) that returns (or resolves to) { rows, total?, hasNext?, nextCursor? }, and optionally values(request) for the set filter’s value list.

The idea is simple once seen, and surprising the first time: the grid builds the request itself and calls your back end. You do not keep the filters, the sort or the page in a store, and you do not cache the rows: the grid owns them. The pictures below follow a made-up observatory’s asteroid catalogue.

A grid that only displays needs the application to carry everything:

user filters ──► store (criteria)
store ─────────► an effect reads the criteria
effect ────────► calls the back end, cancels the older call
effect ────────► puts the rows in the store
store ─────────► the grid shows them
two copies of the same truth

DzGrid with a source does the same work itself:

user filters ──► the grid builds the request
the grid calls source.fetch(request)
the grid keeps the pages, the total,
the loading bar, the error and Retry
one place that knows

Each gesture fills one part of the request; the grid sends it whole, once per change.

the user types « Ves » in Name's filter
└► request.filter = { name: { type: 'text',
op: 'contains', value: 'Ves' } }
the user sorts by Diameter, largest first
└► request.sort = [{ column: 'diameter', dir: 'desc' }]
the user goes to page 3
└► request.page = { index: 2, size: 100, offset: 200 }
your own values, from outside the grid
└► request.params = { from: '2026-01-01', to: '2026-06-30' }
the grid, to cancel an older call
└► request.signal

Say the page has an « observation window » picker above the grid, not a column filter. The window must reach the back end with every request, and changing it must start again from page 1. That is what params is for:

window picker (outside the grid) ──► window (a signal)
│
▼
sourceRows(source, { params: { from, to } })
│ the grid puts params
▼ into EVERY request
source.fetch(request) request.params = { from, to }
│
▼
your translator ──► the body your back end expects:
{ seenAfter: from, seenBefore: to,
…the column filters }

Two things to see in this picture:

  1. params only carries the window to your code; your translator decides where it goes in the body.
  2. A new params tells the grid the question changed: back to page 1, the older call cancelled, the selection cleared — on its own.

The tempting shortcut is to read the window straight from your translator. The request would be right, but the grid would not know it changed: it would stay on page 5 and keep showing the old pages until something else moved.

The translator: your back end keeps its own format

Section titled “The translator: your back end keeps its own format”

The grid sends filters in one well-defined shape; your back end may already speak another one. A small translator sits between them, and the back end does not change:

import { sourceRows, type DataRequest, type DataResponse } from '@dzgrid/core/server';
type Asteroid = { id: string; name: string; found: string; diameter: number };
type Filter = NonNullable<DataRequest['filter']>[string];
/** The observatory's own format: one criterion per column, the operator in `op`. */
type Criterion = { op: string; value?: unknown; values?: readonly unknown[] };
function criterion(f: Filter): Criterion | null {
switch (f.type) {
case 'text': return { op: f.op, value: f.value };
case 'number': return f.op === 'between' ? { op: 'range', values: [f.value, f.valueTo] } : { op: f.op, value: f.value };
case 'day': return f.op === 'between' ? { op: 'range', values: [f.value, f.valueTo] } : { op: f.op, value: f.value };
case 'set': return { op: 'in', values: f.include ?? [] };
default: return null;
}
}
/** The grid's request → the observatory's body (the window from `params` first, the user's filters over it). */
function body(req: DataRequest): Record<string, unknown> {
const criteria: Record<string, Criterion> = {};
const { from, to } = req.params as { from?: string; to?: string };
if (from && to) criteria['found'] = { op: 'range', values: [from, to] };
for (const [column, f] of Object.entries(req.filter ?? {})) {
const c = criterion(f);
if (c) criteria[column] = c;
}
const first = req.sort?.[0];
return { criteria, orderBy: first?.column ?? null, order: first?.dir ?? null };
}
async function fetchPage(req: DataRequest): Promise<DataResponse<Asteroid>> {
const answer = await fetch(`/api/catalogue?page=${req.page?.index ?? 0}&size=${req.page?.size ?? 100}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body(req)),
signal: req.signal as AbortSignal,
});
const json = (await answer.json()) as { items: Asteroid[]; count: number };
return { rows: json.items, total: json.count };
}
/** A new window = a new source with other params = a new question, from page 1. */
export const catalogue = (from: string, to: string) => sourceRows<Asteroid>({ fetch: fetchPage }, { params: { from, to } });
the user types V Ve Ves
requests ──1──► ──2──► ──3──►
✕ cancelled (signal)
✕ cancelled
answers ◄──3── shown
a late answer 2 that still arrives ◄──2── ignored: stale

Pass request.signal to your HTTP call so the network work stops too; the grid ignores a stale answer either way.

The default sort (newest first, say) and the starting window leave with the very first request: one round trip, never « unsorted, then sorted ». Give the sort with gridState({ initial: { v: 1, sort: [...] } }) (Angular: [state]) and the window as params from the start.

Loading, the thin « updating » bar, an error with Retry, the pages, stale answers, a selection that spans every page (« all that match », sent as the query and the rows left out) — without a store, an effect or a cache of yours.

The grid below asks a made-up server inside this page for each page of 5 000 asteroids: filter, sort and turn the pages, and each gesture becomes one request, answered after a short wait.

import { createGrid, type GridMessages } from '@dzgrid/dom';
import { sourceRows, type DataRequest, type DataResponse } from '@dzgrid/core/server';
import { filters } from '@dzgrid/dom/filters';
import { pagination } from '@dzgrid/dom/pagination';
import { EN } from '@dzgrid/dom/locales/en';
import { EN_FILTERS } from '@dzgrid/dom/locales/en-filters';
import { EN_PAGINATION } from '@dzgrid/dom/locales/en-pagination';
type Asteroid = { id: string; name: string; found: string; diameter: number };
async function page(req: DataRequest): Promise<DataResponse<Asteroid>> {
const answer = await fetch('/api/asteroids', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ filter: req.filter, quick: req.quick, sort: req.sort, page: req.page, zone: req.zone }),
signal: req.signal as AbortSignal,
});
return answer.json(); // { rows, total }
}
const messages: GridMessages = { ...EN, text: { ...EN.text, ...EN_FILTERS.text, ...EN_PAGINATION.text } };
createGrid<Asteroid>(document.getElementById('asteroids')!, {
source: sourceRows<Asteroid>({ fetch: page }, { params: { catalogue: 'made-up' } }),
columns: [
{ id: 'name', header: 'Asteroid' },
{ id: 'found', header: 'Found', type: 'day' },
{ id: 'diameter', header: 'Diameter (km)', type: 'number' },
],
getRowId: (a) => a.id,
messages,
operations: 'server',
pagination: { pageSize: 100, pageSizes: [50, 100, 200] },
features: [filters(), pagination()],
onEvent: (e) => {
if (e.type === 'fetchFailed') console.warn('the server did not answer', e.error);
},
});
  • The request: filter (in a wire form: days and instants already resolved in the grid’s zone), quick, sort, page (offset and size, or a cursor with paging: 'cursor'), your params, the zone, a key naming the query, and a signal.
  • The answer: rows, and total when known; without a total, hasNext says whether there is more.
  • Only the answer to the latest request is shown, whatever order answers arrive in; older requests are aborted through signal. A failure shows an error with a retry button and a fetchFailed event.
  • operations places each stage: 'server' (the default with a source) sends filter, search, sort and page to the server; { filter: 'client', sort: 'client', page: 'off' } asks for every row once and finishes in the browser. Server pages imply filter, search and sort on the server too.
  • To fetch again — after a save, on a server push, behind your own « Refresh » button — call reload(): the current page (or the window’s blocks) is asked again, the rows shown stay meanwhile, and the filters, sort, page and selection are kept. It is grid.api.reload() in plain TypeScript, ctrl.reload() on Angular’s DzgController, el.reload() on <dz-grid>. refresh() only paints again and sends no request; Angular’s ctrl.refresh({}) is the older spelling of reload(), and ctrl.refresh({ purge: true }) lets every row go and starts again at the top. On rows you give (rows), there is nothing to fetch: reload() works them out again and paints. The busy setting shows a thin bar while you reload.

autoColumns() (Columns) is a pure function: it does not know about sources. Call it on the rows of the first answer and set the grid’s columns once; later answers do not change them (a filter that empties a page would otherwise take the columns away). The same place can hand the server’s totals to the totals row, when your server sends them.

import { autoColumns } from '@dzgrid/core/auto-columns';
import { sourceRows, type DataRequest, type DataResponse } from '@dzgrid/core/server';
import { createGrid, type GridInstance, type GridMessages } from '@dzgrid/dom';
import { pinnedRows } from '@dzgrid/dom/pinned-rows';
import { totalsRow } from '@dzgrid/dom/totals-row';
import { EN } from '@dzgrid/dom/locales/en';
import { EN_PINNED } from '@dzgrid/dom/locales/en-pinned';
import { EN_TOTALS } from '@dzgrid/dom/locales/en-totals';
type Comet = { id: string; name: string; period: number; seen: string };
type Answer = DataResponse<Comet> & { totals?: Record<string, number> };
const totals = totalsRow();
let grid: GridInstance<Comet> | null = null;
let columnsSet = false;
async function fetchComets(req: DataRequest): Promise<Answer> {
const answer = await fetch('/api/comets', { method: 'POST', body: JSON.stringify({ page: req.page }), signal: req.signal as AbortSignal });
const json = (await answer.json()) as Answer;
if (!columnsSet && json.rows.length) {
columnsSet = true;
const { columns } = autoColumns(json.rows, { hide: ['id'], override: { period: { total: 'sum' } } });
grid?.update({ columns });
}
if (json.totals) totals.api(grid!)?.set(json.totals); // the server's totals; the page's would mislead
return json;
}
const messages: GridMessages = { ...EN, text: { ...EN.text, ...EN_PINNED.text, ...EN_TOTALS.text } };
grid = createGrid<Comet>(document.getElementById('comets')!, {
source: sourceRows<Comet>({ fetch: fetchComets }),
columns: [],
getRowId: (c) => c.id,
messages,
features: [pinnedRows<Comet>(), totals],
});

In Angular, keep the columns in a signal set once from the same place:

import { Component, signal } from '@angular/core';
import { DzGrid, type DzgColumnOrGroup } from '@dzgrid/angular';
import { autoColumns } from '@dzgrid/core/auto-columns';
import { sourceRows, type DataRequest, type DataResponse } from '@dzgrid/core/server';
import { EN } from '@dzgrid/dom/locales/en';
type Comet = { id: string; name: string; period: number; seen: string };
@Component({
selector: 'app-comets',
imports: [DzGrid],
template: `<dz-grid [source]="source" [columns]="columns()" [getRowId]="rowId" [messages]="messages" />`,
})
export class Comets {
readonly columns = signal<DzgColumnOrGroup<Comet>[]>([]);
readonly rowId = (c: Comet) => c.id;
readonly messages = EN;
readonly source = sourceRows<Comet>({ fetch: (req) => this.load(req) });
private async load(req: DataRequest): Promise<DataResponse<Comet>> {
const answer = await fetch('/api/comets', { method: 'POST', body: JSON.stringify({ page: req.page }), signal: req.signal as AbortSignal });
const json = (await answer.json()) as DataResponse<Comet>;
// Once: the first answer with rows makes the columns; later answers leave them.
if (!this.columns().length && json.rows.length) this.columns.set(autoColumns(json.rows, { hide: ['id'] }).columns);
return json;
}
}
import { createGrid } from '@dzgrid/dom';
import { infiniteRows } from '@dzgrid/core/infinite';
import type { DataRequest } from '@dzgrid/core/server';
import { EN } from '@dzgrid/dom/locales/en';
type Star = { id: number; name: string; magnitude: number };
const stars = infiniteRows<Star>({
fetch: async (req: DataRequest) => {
const at = req.page?.offset ?? 0;
const rows = Array.from({ length: 100 }, (_, i) => ({ id: at + i, name: `Star ${at + i}`, magnitude: (at + i) % 7 }));
return { rows, total: 1_000_000 };
},
}, { blockSize: 100, maxBlocks: 20 });
createGrid<Star>(document.getElementById('sky')!, {
source: stars,
columns: [{ id: 'name', header: 'Star' }, { id: 'magnitude', header: 'Magnitude', type: 'number' }],
getRowId: (s) => s.id,
messages: EN,
});

100 000 rows, loaded a hundred at a time while you scroll; sort a column and the list starts again.

Blocks arrive while the user scrolls (ahead blocks in advance, concurrency at a time); at most maxBlocks are kept. Rows not loaded yet show as loading rows; a failed block shows « retry » in its place. With an unknown total, Ctrl+End goes to the last row known, and further each time it is pressed.

serverSideRows(data, { by, aggFuncs }) sends one request per level: the request’s group says which level and which open path; the server answers that level’s groups with their counts and totals, or a group’s rows in blocks. The grid computes no total itself. The server-side-groups.md sheet gives the request and answer shapes; pivot on a server builds on it.

With rows, give a new array, or apply a transaction (rows are matched by id; the focus, selection and open rows stay):

import { createGrid } from '@dzgrid/dom';
import type { ClientModel } from '@dzgrid/core';
import { EN } from '@dzgrid/dom/locales/en';
type Buoy = { id: string; sea: string; wave: number };
const grid = createGrid<Buoy, ClientModel<Buoy>>(document.getElementById('buoys')!, {
rows: [{ id: 'y1', sea: 'Glass sea', wave: 1.2 }],
columns: [{ id: 'sea', header: 'Sea' }, { id: 'wave', header: 'Wave (m)', type: 'number' }],
getRowId: (b) => b.id,
messages: EN,
});
grid.kernel.rows.applyTransaction({ add: [{ id: 'y2', sea: 'Salt bay', wave: 0.4 }], patch: [{ id: 'y1', set: { wave: 2.8 } }] });

In Angular: ctrl.applyTransaction(tx) and ctrl.applyTransactionAsync(tx) (many small updates gathered into one).