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.
- React
- Angular
- Core TypeScript
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}
/>
dataSource: IServerSideDataSource = {
getRows: async ({ request }) => {
const response = await fetch("/api/orders", {
method: "POST",
body: JSON.stringify(request),
});
return response.json();
},
};
<awb-grid
rowModelType="serverSide"
[serverSideDataSource]="dataSource"
[serverSideBlockSize]="12"
[pagination]="true"
[pageSize]="12"
/>
const core = new GridCore(measurer, {
rowModelType: "serverSide",
serverSideBlockSize: 12,
pagination: true,
pageSize: 12,
serverSideDataSource: {
async getRows({ request }) {
return fetchOrders(request); // { rows, totalRows }
},
},
});
await api.refreshServerSideData({ purge: false });
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:
mode | a row passes when |
|---|---|
multiTerm (default) | every whitespace-separated word appears in some cell of the row |
substring | the whole text appears inside one cell |
wholeCell | one 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
dataon 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 whenaddIndexis omitted. Later rows move down.routepicks 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.
- omit it, or pass
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 anaddIndexpast 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.