# 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&nbsp;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.

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

Source: https://pretable.ai/examples/server-data-overview.md

```tsx ServerDataGrid.tsx
"use client";

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

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

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

const EMPTY_QUERY: OrderQuery = { filters: [], sort: [], rowGroups: [] };

const VIEWPORT_HEIGHT = 320;

export function ServerDataGrid() {
  const [query, setQuery] = useState<OrderQuery>(EMPTY_QUERY);
  const [rows, setRows] = useState<Order[]>([]);
  const [total, setTotal] = useState<PretableMatchingTotal>({
    kind: "unknown",
  });
  const [dataState, setDataState] = useState<PretableDataState>({
    phase: "loading",
  });

  // Whether a result has ever committed. The first fetch is `loading` (there is
  // nothing on screen yet); every one after it is `stale` — the previous rows
  // stay up, and stay sortable, while the new ones are in flight.
  const hasCommitted = useRef(false);

  useEffect(() => {
    let cancelled = false;

    setDataState({ phase: hasCommitted.current ? "stale" : "loading" });

    fetchRows(query).then(
      (result) => {
        if (cancelled) return;
        hasCommitted.current = true;
        setRows(result.rows);
        setTotal(result.total);
        setDataState({ phase: "idle" });
      },
      (error: unknown) => {
        if (cancelled) 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",
        });
      },
    );

    return () => {
      cancelled = true;
    };
  }, [query]);

  const onQueryChange = useCallback((next: OrderQuery) => setQuery(next), []);

  return (
    <div>
      <p role="status" style={{ margin: "0 0 8px", fontSize: 13 }}>
        Phase: <code>{dataState.phase}</code>
        {dataState.phase === "error" ? ` — ${dataState.message}` : ""}. Click a
        header or open a funnel: each one is a request, and the server decides
        the rows.
      </p>
      <PretableSurface<Order>
        ariaLabel="Orders"
        columns={columns}
        dataState={dataState}
        getRowId={(row) => row.id}
        onQueryChange={onQueryChange}
        // Declares that the SERVER chose these records and their order. The
        // filter half is taken literally: the query below stays published —
        // the funnel shows it, `onQueryChange` reports it — and the engine
        // stops re-selecting rows with it, so the last result survives on
        // screen while the next one loads. It also buys honest counts, which
        // is the totals page.
        processing={{ filter: "external", sort: "external" }}
        query={query}
        resultMeta={{ total, datasetKey: JSON.stringify(query) }}
        rows={rows}
        viewportHeight={VIEWPORT_HEIGHT}
      />
    </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.
 */
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 surface hands back, 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`. `PretableSurfaceQueryColumns<Order>`
 * is the shape `<PretableSurface>` itself falls back to for accessor-less
 * columns, so this is the type the `query` / `onQueryChange` pair actually
 * speaks.
 */
export type OrderQuery = PretableQueryFor<PretableSurfaceQueryColumns<Order>>;

export interface RowsResponse {
  rows: Order[];
  total: PretableMatchingTotal;
  datasetKey: string;
}

/**
 * The whole of the client's job: send the query, receive rows plus a
 * description of them. The grid never does this — it has no idea a network
 * exists.
 */
export async function fetchRows(
  query: OrderQuery,
  options: { totalKind?: "exact" | "estimate" | "unknown" } = {},
): Promise<RowsResponse> {
  const response = await fetch("/api/docs/rows", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ query, totalKind: options.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;
}
```


## 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](/docs/server-data/totals).

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](/docs/grid/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&nbsp;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](/docs/server-data/lifecycle) 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](/docs/server-data/query-ownership) — the `processing` and `query`/`onQueryChange` contract, what external filtering suppresses, and what it deliberately does not.
- [Loading, staleness, errors](/docs/server-data/lifecycle) — the six `dataState` phases, and which one a given moment actually is.
- [Totals and honesty](/docs/server-data/totals) — `resultMeta.total`, its three kinds, and what select-all and CSV export are allowed to claim when the count is a guess.
- [Windowing](/docs/server-data/windowing) — `resultMeta.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](/docs/server-data/eviction) — dropping the rows you are not showing, and what the grid promises survives it.
