Server-side data Query ownership

Query ownership

Who holds the reader's query, who hears about it, and what declaring external processing authority actually changes — which is the filtering the engine applies, three things the grid claims, and deliberately not the sort.

Two different questions hide behind the word "ownership", and a remote grid has to answer both. The first is who holds the query — the filters, sort, and rowGroups a funnel or a header click produces — and who is told when it changes. The second is who applies it: who decided which records exist and what order they came in. query / onQueryChange answers the first. processing answers the second. They are independent, and the grid behaves differently along each axis.

The grid below answers the first question with the shape nothing else in these docs uses: it passes onQueryChange and no query at all. The engine keeps the reader's intent, and the callback is a notification — the <input defaultValue onChange> of grids. Sort a header and the counter goes up by one; filter Customer for the word fail and the request 500s, which is where the second question gets interesting.

Reporting the query without controlling it

Sort a header or apply a funnel and watch the request counter rise by exactly one: this grid keeps its own query and only reports that it changed, so onQueryChange arrives with no query prop beside it. Filter Customer for the word fail to watch a failed request leave every row it already had on screen, with the filter that failed still showing in the funnel above them.

.md

Processing authority

processing is two fields, each either "engine" (the default) or "external". Setting one to "external" declares that something outside the grid — your backend, in practice — already did that half of the work.

FieldTypeNotes
filter"engine" | "external"Who chose which records exist. Taken literally: the engine stops applying query.filters. The only slice resolveDataScope reads, and half of what aria-rowcount needs.
sort"engine" | "external"Who chose their order. A claim only — the engine goes on ordering the rows it holds. Scope never consults it; the announced count does.

Both are optional, and omitting one is the same as declaring it "engine".

The two halves are not symmetric, and the asymmetry is the thing to carry away. filter: "external" is taken at its word: the engine stops selecting records, which is the section below. sort: "external" is a claim and only a claim; nothing about it stops the engine ordering the rows you handed it.

What both of them change is what the grid is willing to say, and there are exactly three of those:

  • What it announces. aria-rowcount is the count of the whole table, rows not in the DOM included. The grid may only publish the population's count when loaded-model position and dataset position are the same number, which takes both slices external, no grouping, and an exact resultMeta.total: then the attribute is total.count + 1 — the header row is the + 1. An exact total the grid cannot square with the rows it holds gets a warning and the loaded count. A non-exact one publishes -1, ARIA's "unknown", because an estimate cannot be spoken through an attribute whose contract is an integer. Anything short of full external authority publishes the one number the grid can prove: the rows in its own model.
  • What it is allowed to call "all rows". Scope reads processing.filter alone — sort does not enter into it. Under engine filter authority the loaded records are the population, so scope is "all". Under external, it is "all" only when an exact total says you already hold every matching record, and "loaded" otherwise. Every user-facing count label and the CSV export route through that answer, which is why a 200-row window onto 10,432 matches cannot be exported under a heading that says "all".
  • What a filter menu offers. An enum column with no declared options falls back to the distinct values of the records on hand. Under external filtering those records are one server-chosen window, so the funnel would be offering a fragment as if it were the complete universe for isAnyOf — and the engine says so, once per column, the first time such a menu opens. Declaring column.options is the fix, and the example above declares both of its enum columns.

Splitting the two slices is legal and sometimes right — a grid that loads the entire matching result can let the engine order it — but mixed authority over a partial window is the one combination to avoid. Sorting a server-selected window locally reorders a sample, not the population, and the header still reports an ordinary aria-sort over it. If the server chose the records, let it choose the order too.

The grid says so when it can prove it: external filter, engine sort, and an exact resultMeta.total counting more records than are loaded is the one case where "partial" is not a guess, and it warns once per page load. Silence is not a clearance — without an exact total there is nothing to measure the window against, and the combination is no safer for being unprovable.

What external filtering suppresses

filter: "external" is not a hint. In rows mode the surface hands the authority to the row model it owns, and the compiled query plan keeps two versions of the query: the one it reports and the one it applies. Under external filtering the reported one keeps your filters and the applied one has none. get query() — and through it the snapshot, the funnel menu, and onQueryChange — reads the reported version; row evaluation reads the applied one. So the funnel still shows the filter you set, the callback still hands it to you, aria-sort is untouched, and the engine simply stops re-selecting the records you were given.

Suppression changes what is applied, never what is reported.

That distinction costs nothing while the rows and the query agree — the server already filtered by that query, so filtering its answer again removes nothing — and it is the whole point the moment they disagree, which is exactly what stale and error are: a query the reader has moved on to, and rows that answer a different one. The lifecycle deliberately keeps the previous result on screen while the next one loads, and re-applying the new filter to the old rows would empty a body that still holds a perfectly readable result.

You can watch it in the example above. Filter Customer for fail, the request 500s, and every row that was already there is still there under an error strip — the same as notContains fail. Same rows, same failure, and the filter you set is still sitting in the funnel above them.

What suppression does not do is collapse the two counts everywhere. Under engine filter authority a grid can still hold plenty of records and draw none of them, which is why renderBodyState is handed a kind chosen from what the body is drawing rather than from how many rows are loaded.

What suppression does not cover

Three boundaries, because symmetry would be the reasonable assumption:

  • Sort. sort: "external" suppresses nothing: the engine goes on applying query.sort to the rows it holds, so a header click reorders them locally under either value. Over a result the server ordered by that same sort it is a no-op, and it is what leaves a header usable while a request is failing. Deliberate, not an oversight — filtering is where the harm was, a consumer holding a complete window who sorts locally is doing something reasonable, and the one provably dishonest combination already warns, above.
  • Explicit-model mode. The authority reaches the row model the surface constructs, and no further. A model you built is yours: you already decide what goes into its query, so omit the filters the server applied rather than expecting processing to move anything. Passing processing alongside your own model changes what the grid announces and exports, not what it filters.
  • Grouping. rowGroups is not part of processing and never was. What does change under suppression is what a group folds: with no filters applied there is no post-filter subset, so group aggregates and child counts are computed over every loaded row. That is the right answer when the server chose the records, and it is a real behaviour change — a grid grouping a locally-filtered window will report different numbers once it declares external filtering.

Changing the prop while the grid is alive is supported, and it is not free. The value is read during render rather than captured at construction, so flipping it re-declares the authority on the live model, recompiles the plan, and rebuilds the rows through the ordinary cooperative transition. Nothing is re-fetched and no query change is published — the query itself did not move. Fetching stays entirely yours in both directions.

Three ways to own the query

The query props are a two-arm union, PretableQueryOptions, and the arms allow three usable shapes:

ShapePropsWho holds the queryReach for it when
Controlled pairquery and onQueryChangeyouyou need to seed filters, persist them, restore them from a URL, or reject a change — anything the engine cannot decide
Silent uncontrolledneitherthe enginethe rows are already in memory and nothing outside the grid cares what the reader asked for
Notify-onlyonQueryChange alonethe enginesomething outside must react to the query — a fetch, a URL sync, an analytics event — but never override it

query without onQueryChange is not a fourth shape, it is a compile error, and deliberately so: the controlled arm requires the setter the way value requires onChange. A grid whose query is pinned to a value it cannot ask you to change is a grid whose funnels silently do nothing.

ts
export type PretableQueryOptions<TColumns> =
  /** Controlled: `query` requires its setter, as `value` requires `onChange`. */
  | {
      readonly query: PretableQueryFor<NoInfer<TColumns>>;
      readonly onQueryChange: (
        query: PretableQueryFor<NoInfer<TColumns>>,
      ) => void;
    }
  /** Uncontrolled: the engine owns the query, and MAY report changes. */
  | {
      readonly query?: never;
      readonly onQueryChange?: (
        query: PretableQueryFor<NoInfer<TColumns>>,
      ) => void;
    };

Two arms rather than three, and the asymmetry is the point: the uncontrolled arm makes onQueryChange optional rather than forbidden, which is what makes notify-only expressible without a third arm whose only job would be to worsen TypeScript's error text for a malformed pair. No arm lets you set query while the engine also owns it.

Notify-only is enough for the whole of the example above: against the overview's controlled grid, it drops the query prop and the state behind it and keeps everything else. What you give up is anything that needs to write the query — seeding an initial filter, clearing one from a toolbar button, refusing a change. What you get back is that the grid and your state can never disagree, because there is only one copy of the query.

One consequence is worth designing for. A controlled grid fetches from an effect keyed on the query, and an effect comes with a cleanup to cancel a superseded request; notify-only fetches from a callback, which does not. The example keeps a request sequence number and drops any response that is no longer the latest one — because the grid, which does not fetch, cannot debounce, retry, or cancel on your behalf either way.

Type the query correctly and either shape is a one-liner. With plain PretableColumn<Order>[] descriptors, that type is PretableQueryFor<PretableSurfaceQueryColumns<Order>>not PretableQueryFor<typeof columns>, which compiles and resolves every filter to never, because PretableQueryFor reads a column's accessor and plain descriptors have none. See Filtering.

Which props each component accepts

The remote props are not a <PretableSurface> exclusive. <Pretable>, the drop-in, forwards four of them verbatim:

  • processing — the authority claim above, forwarded unchanged; the filter suppression and every honesty rule it feeds live behind the surface. <Pretable> is a rows-mode component throughout, so suppression applies to it exactly as it does to the surface.
  • resultMetatotal, and the datasetKey that says which population the rows belong to.
  • dataState — the phase driving the body-state blocks.
  • onQueryChange — typed as (query: PretableQueryFor<PretableSurfaceQueryColumns<TRow>>) => void, and always the uncontrolled arm.

Two are surface-only, for different reasons. query is absent from <Pretable> by construction: it forwards to the rows-owned mode with its own internal state, so the controlled arm never arises and notify-only is as far as the drop-in goes. renderBodyState is withheld by choice — a drop-in that takes a render callback has stopped being a drop-in — so replacing the loading, empty, or error block means reaching for <PretableSurface>. Relabelling those blocks does not: messages is on both.

See also

  • Server-side data — the section overview, its endpoint, and what the grid keeps owning.
  • Loading, staleness, errors — the six dataState phases, and why a failure never discards rows.
  • Filtering — operator semantics, the funnel menu, and the controlled-query idiom in local form.