Skip to main content

Server-side row model

The server-side model requests block-aligned row slices as the viewport or page needs them. Filtering, sorting, grouping, and full-data aggregation are sent to the data source instead of calculated over an incomplete browser cache.

Loading interactive example…
React
const dataSource: IServerSideDataSource = {
async getRows({ request }) {
const response = await fetch("/api/orders", {
method: "POST",
body: JSON.stringify(request),
});
return response.json(); // { rows, totalRows }
},
};

<Grid
rowModelType="serverSide"
serverSideDataSource={dataSource}
serverSideBlockSize={12}
pagination
pageSize={12}
/>

Request contract​

getRows({ request }) receives startRow, endRow, active filters, ordered sorts, groupBy, the current groupKeys path, and requested aggregates — plus treeParent under server-side tree data and quickFilter while the quick filter holds a search. Return { rows, totalRows }; root responses may also provide columns and a schemaVersion for server-driven schemas.

import type { IServerSideDataSource } from "@agility-workbench/grid";

const serverSideDataSource: IServerSideDataSource = {
async getRows({ request }) {
const response = await fetch("/api/orders", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
});
return response.json(); // { rows, totalRows }
},
};

const options = {
rowModelType: "serverSide",
rowIdKey: "id",
serverSideBlockSize: 100,
serverSideDataSource,
} satisfies GridOptions;

Quick filter​

The quick filter (the floating search box, the toolbar's search, or api.setQuickFilter) works on the server-side row model by handing the search to the server. While the box holds text, every rows request — and every getAggregates request — carries it:

request.quickFilter; // { text: "acme corp", mode: "multiTerm", caseSensitive: false }

The field is absent while the box is empty (or holds only spaces). Apply it together with filters (both must pass) and before grouping, so group rows and their counts reflect it. mode is the end user's match mode:

modea row passes when
multiTerm (default)every whitespace-separated word appears in some cell of the row
substringthe whole text appears inside one cell
wholeCellone cell's entire text equals the text (both sides trimmed)

Matching ignores case unless caseSensitive is true. A change of text, mode or case discards the loaded blocks and refetches, as a column-filter change does, and fires filterChanged with source: "quickFilter". The quick filter's find behavior (highlight matches without filtering) is not available here — the browser never holds the whole dataset to count or step through matches — so a requested behavior: "find" filters through the server instead, with one console.warn.

Row identity​

Give a server-side grid a stable row id: rowIdKey (a field holding the server's key) or getRowId(row). Without one, each row object gets a generated id, and every block refetch (scrolling back after a purge, a refresh, a sort or filter change) hands back new objects with new ids — selection, refresh-in-place and undo history then cannot follow the row. The grid logs a one-time console.warn on the first load when neither option is set.

Failed requests​

When getRows calls error(…), rejects, or throws, the grid emits an error event (code: "row_model_error", the failure in details), hides the loading overlay, and leaves the block's rows unloaded. It does not retry on its own: the block is requested again the next time it is needed — scrolling over the gap — or by api.refreshServerSideData().

To surface the failure, pass onError (React: the onError prop; Angular: the (gridError) output) or subscribe with api.on("error", …). Either is an extra notification: the handling above happens whether or not anything listens. A failure of a request a newer one has already superseded is not reported.

const grid = createGrid(el, {
rowModelType: "serverSide",
serverSideDataSource,
onError: (event) => showToast(`Could not load rows: ${event.message}`),
});

Cache size​

Loaded blocks stay in memory until a purge, a refresh, or a sort, filter or grouping change discards them. To bound memory on a long scroll, set serverSideMaxBlocks (creation-only):

const options = {
rowModelType: "serverSide",
serverSideBlockSize: 100,
serverSideMaxBlocks: 20, // at most ~2,000 rows held
serverSideDataSource,
} satisfies GridOptions;

A block is one serverSideBlockSize range of one listing — the root rows, or one expanded parent's children. Whenever a block arrives and more than serverSideMaxBlocks are loaded across all listings, the grid evicts the least recently used ones (a block is used when it loads and whenever it is on screen) until the count fits. It never evicts:

  • a block in the viewport or one block either side of it;
  • a block inside the window the grid is still loading, such as the first page of rows after a load, sort or filter, or the rows a page change or an expand needs. When that window holds more blocks than the limit, the cache goes over the limit until the window has loaded, then comes back down;
  • a block whose request is still in flight;
  • a block holding an expanded group row or tree parent, or a parent whose children are still loading — dropping that row would orphan its children, so such blocks count toward the limit but stay.

When nothing else can go, the cache holds more than the limit rather than drop one of these. Eviction removes rows only: row counts and the pager's total, expansion state, and the selection (kept by row id) are unchanged, and an evicted range is requested again when it is scrolled back into view. A collapsed parent's cached children leave with its row and reload on the next expand. Unset, nothing is evicted.

Server-provided schema​

const serverSideDataSource: IServerSideDataSource = {
async getRows({ request }) {
return {
rows: await loadRows(request),
totalRows: 500,
schemaVersion: "orders-v2",
columns: [
{ key: "orderNo", label: "Order" },
{ key: "total", label: "Total", type: ColumnType.CURRENCY },
],
};
},
};

A server-driven schema applies only while the application has not supplied column definitions itself — through columnDefs at creation, a wrapper prop, updateGridOptions({ columnDefs }), or api.setColumnDefs. Application definitions always win; release them with updateGridOptions({ columnDefs: undefined }) to let the server's schema apply again. An unchanged schema is deduped by schemaVersion (or by structural signature when no version is sent), so resending it with every block is cheap.

Omitting totalRows creates an open-ended listing. The grid probes forward until a short block establishes the end and displays provisional pagination totals with a + suffix.

Tree requests​

With server-side tree data (treeData: { mode: "server" }) the hierarchy is the rows' own rather than a column-value grouping, and a request asks for one parent's children through request.treeParent:

const serverSideDataSource: IServerSideDataSource = {
async getRows({ request }) {
const parent = request.treeParent; // undefined for the root listing
const rows = await loadChildren({
parentId: parent?.id ?? null,
offset: request.startRow,
limit: (request.endRow ?? 0) - (request.startRow ?? 0),
sorts: request.sorts,
filters: request.filters,
});
return { rows, totalRows: await countChildren(parent?.id ?? null) };
},
};

treeParent carries the parent's id (its grid row ID), path (the row IDs from the root down to and including the parent), and data (the parent row object exactly as the server returned it). startRow/endRow are relative to that parent's children, and totalRows is its immediate-children count — the same open-ended-listing rules apply per parent. In tree mode groupBy, groupKeys, and aggregates are always empty.

A sort or filter on the generated hierarchy column arrives under that column's key, which is __pte_tree__ unless treeData.columnDef.key names the server field the labels come from.

Whole-dataset totals​

The footer's aggregate scope offers Current page, summed in the browser over the page's rows, and Entire dataset. On the server-side row model the browser never holds the whole dataset, so Entire dataset needs a server aggregation source: implement getAggregates on the data source, or pass serverSideAggregationSource (which wins when both exist). The grid asks it for one value per aggregated column, keyed by the column's field, under the current filters:

const serverSideDataSource: IServerSideDataSource = {
async getRows({ request }) {
/* … */
},
async getAggregates({ request }) {
// request.aggregates: [{ key: "total", type: "sum" }, …]; request.filters and
// request.quickFilter as in getRows
return { values: await loadTotals(request) }; // e.g. { total: 1234567 }
},
};

Without a source the scope is locked to Current page: a programmatic or restored "all" becomes "page", and Entire dataset stays in the footer's list greyed out, with a tooltip saying what would enable it, on the control and in the footer's overflow menu alike. paginationControls.aggregateScope tunes that: whenUnavailable: "hidden" drops the choice from both lists, and unavailableMessage replaces the tooltip with wording for your users.

Selecting all rows​

With row selection on, api.selectAllRows() and the header checkbox select every row the server's current query returns, including rows not fetched yet. The grid records this as { selectAll: true, toggledRowIds }, where toggledRowIds lists the rows the user un-checked. Read it with api.getRowSelectionState() and send it, with the filter and quick filter, to run a bulk operation on the server. getSelectedRows() and exports only see loaded rows. A filter or quick-filter change ends the select-all; refreshServerSideData() and paging keep it. See Server-side select-all.

Export​

An export writes what the client holds, in the shape on screen: the loaded group or tree rows, an expanded parent followed by the children the grid has fetched, a collapsed one alone, and nothing for blocks never requested — across every page, not only the current one. The row-selection and cell-range scopes prune that tree exactly as on the client-side model.

Aggregate cells are the numbers the grid displays, written as values rather than formulas: a group row's subtotal is the one the server stamped on it (it covers rows that were never fetched), its count is the one on screen (getGroupChildCount's, as groupChildCount presents it, or none), and the footer is the footer's — the current page's sum or the server's whole-dataset total. A formula over the exported rows could not reproduce any of them. Produce full extracts on the server.

Refresh server data​

await api.refreshServerSideData();
await api.refreshServerSideData({ purge: true });
// Grouping: one group subtree, by raw group path.
await api.refreshServerSideData({ groupKeys: [{ key: "region", value: "EMEA" }] });
// Server tree data: one row's subtree, by row ID.
await api.refreshServerSideData({ rowId: "folder-42", purge: true });

Use a soft refresh to keep visible rows while refetching, or { purge: true } to discard affected blocks immediately. A groupKeys path or a rowId limits the refresh to one subtree — that parent's children listing and every listing below it; the two are mutually exclusive. Expansion state survives either way. The call resolves false when the named subtree is not one the store holds (an unknown group path, or a row that has never been expanded, so nothing below it is loaded).

Undo/redo history is cleared (one historyChanged with reason: "clear") whenever the loaded rows are discarded: a { purge: true } refresh and any reload a sort, filter, or grouping change triggers. A soft refresh refetches the edited rows in place and keeps the history.

For server-side pagination with expensive counts, pair an open-ended listing with a hint:

const options = {
rowModelType: "serverSide",
pagination: true,
pageSize: 50,
paginationUnknownTotalTooltip: "The server is still discovering the total",
serverSideDataSource,
} satisfies GridOptions;

Transactions​

applyTransaction and applyTransactionAsync work on the server-side model. A transaction mirrors a change your application has already made on the server into the rows the grid has loaded, so they change without a refetch. The grid tells the server nothing. Every block fetched after the transaction must agree with it: same rows, same order, same counts.

// The server deleted order 17 and inserted order 90 at the top of the EMEA / UK group.
api.applyTransaction({ remove: ["17"] });
api.applyTransaction({
add: [{ id: "90", region: "EMEA", country: "UK", amount: 120 }],
route: ["EMEA", "UK"],
addIndex: 0,
});
// Server tree data: the route is the parent row IDs from the root down.
api.applyTransaction({ add: [newFile], route: ["projects", "folder-42"] });
  • update replaces a loaded row's data on the same node, so selection, focus, pins and expansion stay. The row repaints in place, with no reorder and no refetch. A group row reads its server-stamped aggregates and child count again from the new data. Nothing is recomputed, though: updating a leaf does not change its group's totals. Send the group row an update too, or refresh it.

  • remove takes a loaded row out of its listing. Later rows move up, and the listing count and the pager total drop. Removing a group row or a tree parent takes its loaded subtree with it. Removed rows leave the selection.

  • add inserts rows into one listing at addIndex, or appends them when addIndex is omitted. Later rows move down. route picks the listing:

    • omit it, or pass [], for the root listing;
    • with grouping, pass the group values from the top level down (a path shorter than the grouping adds group rows at that level);
    • with server tree data, pass the parent row IDs from the root down.

    An ID that is already loaded updates that row instead, the same as on the client-side model.

Some things are skipped and not counted in the returned { added, updated, removed }:

  • updates and removals of rows that are not loaded (to reload a range, use refreshServerSideData);
  • additions under a parent that is not loaded or not expanded (the server returns them when the parent is expanded);
  • additions to an open-ended listing (no totalRows, end not reached yet) that append, or that give an addIndex past the loaded rows. The grid does not know where that listing ends.

A block requested before an add or remove answers for the old positions. When it arrives, the grid discards it and requests the visible part of that listing again. Pure updates keep their in-flight blocks. A transaction keeps the undo history, the same as on the client-side model.