# 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.

### Example: 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.

Source: https://pretable.ai/examples/server-query-ownership.md

```tsx NotifyOnlyGrid.tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

import { Pretable, type PretableDataState } from "@pretable/react";

import { columns } from "./columns";
import { fetchRows, type Order, type OrderQuery } from "./fetch-rows";

/** What the grid starts with, and so what the first request asks for. */
const EMPTY_QUERY: OrderQuery = { filters: [], sort: [], rowGroups: [] };

export function NotifyOnlyGrid() {
  const [rows, setRows] = useState<Order[]>([]);
  const [requests, setRequests] = useState(0);
  const [dataState, setDataState] = useState<PretableDataState>({
    phase: "loading",
  });

  // Whether a result has ever committed: the first fetch is `loading` (nothing
  // is on screen yet), every one after it is `stale`.
  const hasCommitted = useRef(false);

  // Notify-only hands you a callback, not an effect, so there is no cleanup to
  // run when a newer query supersedes an in-flight one. A sequence number does
  // the same job: a response that is no longer the latest request's is dropped
  // rather than committed out of order.
  const latestRequest = useRef(0);

  const load = useCallback((query: OrderQuery) => {
    const requestId = latestRequest.current + 1;
    latestRequest.current = requestId;
    setRequests(requestId);
    setDataState({ phase: hasCommitted.current ? "stale" : "loading" });

    fetchRows(query).then(
      (result) => {
        if (requestId !== latestRequest.current) return;
        hasCommitted.current = true;
        setRows(result.rows);
        setDataState({ phase: "idle" });
      },
      (error: unknown) => {
        if (requestId !== latestRequest.current) return;
        hasCommitted.current = true;
        // Rows are left untouched: a failed request never discards the last
        // result that did answer.
        setDataState({
          phase: "error",
          message: error instanceof Error ? error.message : "Request failed",
        });
      },
    );
  }, []);

  // The one request the grid cannot ask for: nothing changed yet, so
  // `onQueryChange` has not fired.
  useEffect(() => {
    load(EMPTY_QUERY);
  }, [load]);

  return (
    <div>
      <p role="status" style={{ margin: "0 0 8px", fontSize: 13 }}>
        Requests sent: <code data-testid="request-count">{requests}</code> —
        phase <code>{dataState.phase}</code>
        {dataState.phase === "error" ? ` (${dataState.message})` : ""}. The grid
        holds the query; it only tells you when it changed.
      </p>
      <Pretable<Order>
        ariaLabel="Orders"
        columns={columns}
        dataState={dataState}
        getRowId={(row) => row.id}
        // No `query` prop — `<Pretable>` does not accept one. The engine owns
        // the reader's intent and reports it here, which is enough to fetch
        // against and one prop less to keep in sync.
        onQueryChange={load}
        // Declares that the SERVER chose these records and their order. The
        // filter half is taken literally: the engine keeps reporting the query
        // above — the funnel shows it, `onQueryChange` fires with it — and
        // stops re-selecting rows with it, which is what keeps these rows on
        // screen while a request is in flight or has failed. The sort half is a
        // claim only; a header click still reorders them here.
        processing={{ filter: "external", sort: "external" }}
        rows={rows}
      />
    </div>
  );
}
```

```ts columns.ts
import type { PretableColumn } from "@pretable/react";

import type { Order } from "./fetch-rows";

/**
 * Every `type` here must agree with what the endpoint believes the column is:
 * the filter menu offers operators by type, and an operator the server's column
 * type cannot use comes back as a 500 rather than as an unfiltered grid.
 *
 * The two `enum` columns declare their `options` rather than letting the
 * checklist auto-derive. Under external filtering the rows on hand are one
 * server-chosen window, so derived values would offer whichever of them
 * happened to load as if it were the complete universe — and the engine says
 * so, out loud, the first time such a funnel opens.
 */
export const columns: PretableColumn<Order>[] = [
  { id: "customer", header: "Customer", widthPx: 150 },
  {
    id: "region",
    header: "Region",
    type: "enum",
    widthPx: 90,
    options: [
      { value: "North" },
      { value: "South" },
      { value: "East" },
      { value: "West" },
    ],
  },
  {
    id: "status",
    header: "Status",
    type: "enum",
    widthPx: 100,
    options: [
      { value: "open" },
      { value: "shipped" },
      { value: "delivered" },
      { value: "cancelled" },
    ],
  },
  { id: "total", header: "Total", type: "number", widthPx: 90 },
  { id: "placedAt", header: "Placed", type: "date", widthPx: 100 },
];
```

```ts fetch-rows.ts
import type {
  PretableMatchingTotal,
  PretableQueryFor,
  PretableSurfaceQueryColumns,
} from "@pretable/react";

export interface Order {
  id: string;
  customer: string;
  region: string;
  status: string;
  total: number;
  placedAt: string;
}

/**
 * The query the grid reports, typed once and shared by everything that touches
 * it.
 *
 * Not `PretableQueryFor<typeof columns>`: these are plain `PretableColumn<Order>`
 * descriptors with no `accessor` field, and `PretableQueryFor` needs one to
 * resolve a filter to anything but `never`. Both spellings compile, so nothing
 * would have told you — every filter would just be `never`.
 * `PretableSurfaceQueryColumns<Order>` is the shape the component itself falls
 * back to for accessor-less columns, so it is the type `onQueryChange` speaks.
 */
export type OrderQuery = PretableQueryFor<PretableSurfaceQueryColumns<Order>>;

export interface RowsResponse {
  rows: Order[];
  /**
   * How many records matched. This example never publishes it — with no
   * `resultMeta`, the grid counts the rows it holds — because notify-only is
   * the subject here. See the totals page for the honest version.
   */
  total: PretableMatchingTotal;
  datasetKey: string;
}

/**
 * The whole of the client's job: send the query, receive rows. The grid never
 * does this — it has no idea a network exists, which is why the query has to
 * reach you through a callback before anything can be fetched.
 */
export async function fetchRows(query: OrderQuery): Promise<RowsResponse> {
  const response = await fetch("/api/docs/rows", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ query, totalKind: "exact" }),
  });

  if (!response.ok) {
    const problem = (await response.json().catch(() => null)) as {
      message?: string;
    } | null;

    throw new Error(problem?.message ?? "Order service unavailable");
  }

  return (await response.json()) as RowsResponse;
}
```


## 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.

| Field    | Type                     | Notes                                                                                                                                                                       |
| -------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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](/docs/server-data/query-ownership#what-external-filtering-suppresses). `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](/docs/server-data/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`](/docs/server-data/lifecycle#replacing-the-built-in-blocks) 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:

| Shape               | Props                           | Who holds the query | Reach for it when                                                                                                       |
| ------------------- | ------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Controlled pair     | `query` **and** `onQueryChange` | you                 | you need to seed filters, persist them, restore them from a URL, or reject a change — anything the engine cannot decide |
| Silent uncontrolled | neither                         | the engine          | the rows are already in memory and nothing outside the grid cares what the reader asked for                             |
| Notify-only         | `onQueryChange` alone           | the engine          | something 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](/docs/server-data), 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.

<Callout type="note">
  Both query props belong to rows mode. In explicit-model mode, call
  `rowModel.setQuery(...)`; passing them to the surface is a type error.
</Callout>

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](/docs/grid/filtering#controlling-the-query).

## 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.
- **`resultMeta`** — `total`, 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](/docs/server-data) — the section overview, its endpoint, and what the grid keeps owning.
- [Loading, staleness, errors](/docs/server-data/lifecycle) — the six `dataState` phases, and why a failure never discards rows.
- [Filtering](/docs/grid/filtering) — operator semantics, the funnel menu, and the controlled-query idiom in local form.
