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.
How it works, in pictures
Section titled “How it works, in pictures”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.
Who does what
Section titled “Who does what”A grid that only displays needs the application to carry everything:
user filters ──► store (criteria)store ─────────► an effect reads the criteriaeffect ────────► calls the back end, cancels the older calleffect ────────► puts the rows in the storestore ─────────► the grid shows them
two copies of the same truthDzGrid 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 knowsThe request, filled in as the user works
Section titled “The request, filled in as the user works”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.signalparams: what comes from outside the grid
Section titled “params: what comes from outside the grid”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:
paramsonly carries the window to your code; your translator decides where it goes in the body.- A new
paramstells 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 } });Cancelling: only the latest answer counts
Section titled “Cancelling: only the latest answer counts”the user types V Ve Vesrequests ──1──► ──2──► ──3──► ✕ cancelled (signal) ✕ cancelledanswers ◄──3── showna late answer 2 that still arrives ◄──2── ignored: stalePass request.signal to your HTTP call so the network work stops too; the grid ignores a stale answer either way.
The first screen, in one call
Section titled “The first screen, in one call”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.
What the grid then does on its own
Section titled “What the grid then does on its own”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); },});import { Component, computed, signal } from '@angular/core';import { DzGrid, type DzgColumnOrGroup } from '@dzgrid/angular';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, sort: req.sort, page: req.page, window: req.params }), signal: req.signal as AbortSignal, }); return answer.json();}
@Component({ selector: 'app-asteroids', imports: [DzGrid], template: ` <label>From <input type="date" [value]="from()" (change)="from.set($any($event.target).value)" /></label> <dz-grid [source]="source()" [columns]="columns" [getRowId]="rowId" [messages]="messages" operations="server" [pagination]="{ pageSize: 100, pageSizes: [50, 100, 200] }" [features]="features" (fetchFailed)="failed = true" />`,})export class Asteroids { readonly from = signal('2026-01-01'); // A new window is a new source: the grid goes back to page 1 and cancels the older call. readonly source = computed(() => sourceRows<Asteroid>({ fetch: page }, { params: { from: this.from() } })); readonly columns: DzgColumnOrGroup<Asteroid>[] = [ { id: 'name', header: 'Asteroid' }, { id: 'found', header: 'Found', type: 'day' }, { id: 'diameter', header: 'Diameter (km)', type: 'number' }, ]; readonly rowId = (a: Asteroid) => a.id; readonly messages = { ...EN, text: { ...EN.text, ...EN_FILTERS.text, ...EN_PAGINATION.text } }; readonly features = [filters(), pagination()]; failed = false;}import '@dzgrid/element/define';import type { DzGridElement } from '@dzgrid/element';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, sort: req.sort, page: req.page }), signal: req.signal as AbortSignal, }); return answer.json();}
const grid = document.querySelector<DzGridElement<Asteroid>>('dz-grid')!;grid.columns = [ { id: 'name', header: 'Asteroid' }, { id: 'found', header: 'Found', type: 'day' }, { id: 'diameter', header: 'Diameter (km)', type: 'number' },];grid.getRowId = (a) => a.id;grid.messages = { ...EN, text: { ...EN.text, ...EN_FILTERS.text, ...EN_PAGINATION.text } };grid.features = [filters(), pagination()];grid.settings = { operations: 'server', pagination: { pageSize: 100, pageSizes: [50, 100, 200] } };grid.source = sourceRows<Asteroid>({ fetch: page });- The request:
filter(in a wire form: days and instants already resolved in the grid’s zone),quick,sort,page(offsetandsize, or a cursor withpaging: 'cursor'), yourparams, thezone, akeynaming the query, and asignal. - The answer:
rows, andtotalwhen known; without a total,hasNextsays 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 afetchFailedevent. operationsplaces 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 isgrid.api.reload()in plain TypeScript,ctrl.reload()on Angular’sDzgController,el.reload()on<dz-grid>.refresh()only paints again and sends no request; Angular’sctrl.refresh({})is the older spelling ofreload(), andctrl.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. Thebusysetting shows a thin bar while you reload.
Columns from the first answer
Section titled “Columns from the first answer”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; }}Infinite scroll
Section titled “Infinite scroll”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.
Groups on the server
Section titled “Groups on the server”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.
Rows that change
Section titled “Rows that change”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).