Server-side data Server-side data
Server-side data
Hand filtering, sorting, and counting to a backend: what the grid still owns, what you owe it, and how the endpoint in these examples behaves.
When the rows live in a database, the grid stops being the thing that decides which of them exist. It publishes what the reader asked for — the funnel they opened, the header they clicked — and renders the answer you bring back. Everything between those two moments is yours: the request, the filtering, the ordering, the count, and the story you tell while it is in flight.
Nothing about that is a different component. It is <PretableSurface> with four props: query/onQueryChange to own the reader's intent, processing to say who has authority over filters and sort, dataState to say where the request is, and resultMeta to describe the result you got back. The example below wires all four to a real endpoint with a real 500 ms delay, so the pauses are the actual thing, not a description of one. Sort a header, open a funnel — each is one POST, and the rows that come back were filtered and ordered on the other side of it.
Every header sort and column filter becomes one POST to /api/docs/rows with a 500ms delay, and the rows that come back were filtered and ordered there. The grid's job is to publish what the reader asked for and render the answer.
What the grid owns
External processing moves less than people expect. The reader's intent, the interaction state, and the geometry stay in the grid; the data itself becomes yours.
| Concern | Owner | Notes |
|---|---|---|
| Query intent | grid | funnels, header clicks, and the group panel still produce filters, sort, and rowGroups — you receive them |
| Focus, selection, editing | grid | keyboard, marquee, and cell editors work identically against server-supplied rows |
| Viewport geometry | grid | row virtualization, column layout, pinning, and resizing never consult where the rows came from |
| Fetching | consumer | the grid issues no requests; nothing in it knows a network exists |
| Choosing the records | consumer | processing.filter: "external" declares that the server, not the engine, decided which records exist — and the engine stops re-selecting them |
| Choosing the order | consumer | processing.sort: "external" says the same about order but suppresses nothing; leaving it to the engine over a partial window sorts a sample |
| Totals | consumer | the row count is whatever resultMeta.total claims, and how sure you are of it is part of the claim |
| Lifecycle | consumer | dataState is never inferred and has no default — loading, staleness, and failure are things you declare |
Two things about processing are worth stating plainly, because "external" reaches further than one slice and less far than the other.
filter: "external" stops the engine selecting records, without changing what it reports. The published filters stay published — the funnel still shows them, onQueryChange still hands them to you — and the engine stops re-applying them to the rows you brought back, because you already did. That matters exactly when the rows and the query disagree, which the lifecycle deliberately allows: while a new result loads, the previous one is still on screen answering the previous query. In the example above, a request that fails leaves the previous rows in place and leaves them readable — filter Customer for fail and the body keeps every row it already had, with an error strip above it, the same as notContains fail. Sort is not part of that bargain: sort: "external" is a claim about the order you supplied, and the engine still orders what it is given.
What the claim does buy is honesty about counts, and it cuts both ways. With both slices external and an exact total, aria-rowcount may publish the whole population instead of just the rows in the model, because loaded position and dataset position finally line up. In the other direction, declaring external filtering narrows what a select-all or a CSV export is allowed to call "all rows": unless the exact total says you already hold every matching record, the answer is the loaded ones. That is Totals and honesty.
And because the grid does not fetch, it also cannot retry, debounce, or cancel. The example above cancels superseded responses itself, with a flag in its effect cleanup.
The endpoint these examples use
Every page in this section talks to one route, POST /api/docs/rows. It serves 480 fixture orders and applies the same operator semantics the local engine does, so a filter behaves the way Filtering describes it whichever side runs it.
The request body is the published query, plus optional paging and a hint about how sure the count should be:
{
"query": {
"filters": [
{ "columnId": "region", "operator": "isAnyOf", "value": ["North"] }
],
"sort": [{ "columnId": "total", "direction": "desc" }],
"rowGroups": []
},
"offset": 0,
"limit": 100,
"totalKind": "exact"
}The response is the three things it takes to describe a result — the rows, how many matched, and an identifier for the set they came from:
{
"rows": [
{
"id": "ord-0001",
"customer": "Aldridge Foods",
"region": "North",
"status": "open",
"total": 250,
"placedAt": "2026-01-01"
}
],
"total": { "kind": "exact", "count": 120 },
"datasetKey": ""
}Two behaviors are deliberate. Every response waits 500 ms before it is sent, which is long enough that loading and stale are states you can watch rather than infer. And any filter whose value contains fail returns a 500, which is how the lifecycle page reaches the error phase on demand. A filter the fixture genuinely cannot answer — an unknown column, an operator its column's type cannot use, a missing operand — also returns a 500 with a message saying which, rather than quietly returning every row.
That last point is a rule to copy, not a fixture quirk: a backend that ignores a filter it does not understand produces a grid that looks filtered and is not.
Where to go next
- Query ownership — the
processingandquery/onQueryChangecontract, what external filtering suppresses, and what it deliberately does not. - Loading, staleness, errors — the six
dataStatephases, and which one a given moment actually is. - Totals and honesty —
resultMeta.total, its three kinds, and what select-all and CSV export are allowed to claim when the count is a guess. - Windowing —
resultMeta.windowfor a result too big to hold, thewindowGapsignal that says when to fetch, and the one rule that keeps the positions the grid announces from contradicting the count. - Eviction — dropping the rows you are not showing, and what the grid promises survives it.