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.

A grid whose filtering and sorting happen on the server

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.

.md

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.

ConcernOwnerNotes
Query intentgridfunnels, header clicks, and the group panel still produce filters, sort, and rowGroups — you receive them
Focus, selection, editinggridkeyboard, marquee, and cell editors work identically against server-supplied rows
Viewport geometrygridrow virtualization, column layout, pinning, and resizing never consult where the rows came from
Fetchingconsumerthe grid issues no requests; nothing in it knows a network exists
Choosing the recordsconsumerprocessing.filter: "external" declares that the server, not the engine, decided which records exist — and the engine stops re-selecting them
Choosing the orderconsumerprocessing.sort: "external" says the same about order, and is acted on the same way; leaving it to the engine over a partial window sorts a sample
Totalsconsumerthe row count is whatever resultMeta.total claims, and how sure you are of it is part of the claim
LifecycleconsumerdataState 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: "external" is the same bargain one axis over: the sort stays published and the engine stops re-applying it, so a window the server ranked keeps that ranking instead of being re-sorted as a sample.

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:

json
{
  "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:

json
{
  "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, or an AND/OR group — 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.

What a filter looks like on the wire

query.filters is an array, and it always was. What changed is what an element of it may be: either a leaf — the { columnId, operator, value } shape in the request above — or a group, { "op": "and" | "or", "children": [...] }, whose children are themselves leaves or groups, to any depth. filters is a tree, and it reaches onQueryChange and then your endpoint exactly as the grid built it. Nothing flattens, rewrites, or simplifies it on the way out, and external filter authority does not either — suppression decides what the engine applies, never what it reports.

json
{
  "query": {
    "filters": [
      { "columnId": "total", "operator": "gt", "value": 500 },
      {
        "op": "or",
        "children": [
          { "columnId": "region", "operator": "isAnyOf", "value": ["North"] },
          { "columnId": "customer", "operator": "contains", "value": "Labs" }
        ]
      }
    ],
    "sort": [],
    "rowGroups": []
  }
}

That payload reads total > 500 AND (region is North OR customer contains "Labs"), and the four rules that make it mean that are the contract.

The top-level array is an implicit AND. It is what a list of filters has always meant — each entry narrows the result further — so groups became elements of that array rather than a new field beside it, and the ordinary one-leaf-per-column case stays the flat list it was. Two consequences follow from the same rule. A payload written before groups existed is still a correct payload. And "filters": [] constrains nothing, because an AND over no conditions excludes no rows.

Leaves and groups discriminate on structure, not on a tag. There is no kind field to switch on: a group is the node carrying op and children, a leaf is the node carrying columnId and operator. On the client edge, isPretableFilterGroup — exported from both @pretable/core and @pretable/react — makes that test and narrows PretableFilterNodeFor to PretableFilterGroupFor, so nothing has to hand-roll it. On the server there are no types left to narrow: the query arrived as JSON over HTTP, so write the test yourself, and test for children. That is the field a group cannot exist without, and the field a leaf never has.

An empty group matches every row, under either op. Naive boolean algebra says an empty or is false, and that is exactly the wrong answer here: a group with nothing in it is a group someone is part-way through building, and a half-built condition that blanks the grid mid-edit is a bug the reader will read as data loss. So an empty group constrains nothing whichever way it joins — the same answer an empty top-level array gives, for the same reason. Copy that rule into your backend rather than deriving it, or the two sides will disagree about a query the grid considers unfiltered.

Nesting is bounded at 64 levels. A tree deeper than that is rejected with the same typed invalid-query error an unknown column gets, and the message breadcrumbs the offending node — query.filters[0].children[3].children[1] — so you are told where, not just that. The bound exists because every consumer of a captured query recurses over it, and it sits far above any tree a person or a builder UI produces and far below the depth at which any of that recursion is at risk. In practice the grid rejects a too-deep tree before it can publish one, so your endpoint should never see it; bound your own recursion anyway, since a query can also arrive from a saved view, a URL, or a client that is not this grid.

A server that only understands flat filters has to decide

It cannot be left implicit, because the failure mode of guessing is the one this page keeps warning about: a grid that looks filtered and is not. Three answers are defensible, and which one is right is a property of your backend, not of the grid.

  • Reject. Answer with an error the moment a group appears, naming it. Cheapest by far, correct at once, and it fails where a reader can see it — an error strip over the rows they had, rather than a result quietly computed from half of what they asked for. This is what the fixture endpoint above does: applyDocsQuery in app/api/docs/rows/dataset.ts scans filters for children before it reads a single row, and returns a 500 that says so. Scanning up front rather than inside the row predicate is the part worth copying — a per-row check is reachable only if some row survives the leaves ahead of it, so a leaf matching nothing would have answered an empty result instead of an error. Whether a query is one you can answer is a question about the query, and it is asked once. It is the right posture for a demo, and the right first commit for a real backend too, because it buys you the freedom to implement groups later without having shipped a wrong answer in the meantime.
  • Flatten — but only when every join is and. A tree whose groups all carry "op": "and" is genuinely equivalent to the flat list of its leaves, nesting and all, so collecting them loses nothing. The trap is that this is only true until the first "op": "or", and an or is precisely what a user reaches for a group to express. So the flattening has to be conditional, and its else-branch has to be reject, never best-effort: a tree containing an or cannot be approximated by an AND of its leaves in either direction. Note that an empty group contributes no leaves, which is the correct reading of the rule above.
  • Implement the recursion. It is smaller than it sounds — map a leaf to a predicate as you already do, join a group's children with AND or OR, parenthesize each group, and return the identity TRUE for an empty one. The work you actually owe is the parameter binding you owe leaves anyway, over a shape that now nests; a tree of user-supplied operators and operands assembled into SQL by string concatenation is an injection hole whatever its depth.

Where to go next

  • Query ownership — the processing and query/onQueryChange contract, what external filtering suppresses, and what it deliberately does not.
  • Loading, staleness, errors — the six dataState phases, and which one a given moment actually is.
  • Totals and honestyresultMeta.total, its three kinds, and what select-all and CSV export are allowed to claim when the count is a guess.
  • WindowingresultMeta.window for a result too big to hold, the windowGap signal 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.