# Totals and honesty

resultMeta.total is a claim about how many records matched, in one of three strengths — and the strength, not the number, decides what the grid announces and what an export is allowed to call all rows.


A grid that holds a server's answer knows nothing about the records it was not sent. Everything it says about the size of the result — the count a screen reader hears, the label on a CSV file — comes from one prop, `resultMeta.total`, and it cannot check that prop against the data, because the data it would need is exactly the part it does not have.

So the type does not ask you for a number. It asks for a claim, and for how strong the claim is. `PretableMatchingTotal` has three shapes — `exact`, `estimate`, `unknown` — and the strength is the load-bearing half: one of the three unlocks things, and the other two exist so that a server which does not know the count has something honest to send.

The grid below sends the same request three ways. The rows never change: the endpoint returns all 480 orders whichever button you pick, because nothing asks it for a page. Only the claim about them changes, and with it what the grid is prepared to say. Its funnels and header sorts are switched off, unlike every other grid in this section — this one's query never leaves the client, and under the `filter: "external"` it declares, a funnel would set a filter that [nothing applies](/docs/server-data/query-ownership#what-external-filtering-suppresses).

### Example: One result set, three claims about how many rows it has

The radio buttons change nothing about the records — the same 480 orders come back every time — only how sure the server says it is of the count. Watch what that alone changes: what an export may call all rows, and what the grid is willing to announce as aria-rowcount.

Source: https://pretable.ai/examples/server-totals.md

```tsx TotalsGrid.tsx
"use client";

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

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

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

const KINDS: TotalKind[] = ["exact", "estimate", "unknown"];

/** The query never changes here; only the confidence of the count does. */
const EMPTY_QUERY: OrderQuery = { filters: [], sort: [], rowGroups: [] };

const PROCESSING = { filter: "external", sort: "external" } as const;

/**
 * The same population whichever total is claimed: the radio buttons change the
 * server's confidence, not which records matched, so the key must NOT change
 * with them.
 */
const DATASET_KEY = JSON.stringify(EMPTY_QUERY);

const VIEWPORT_HEIGHT = 320;

export function TotalsGrid() {
  const [totalKind, setTotalKind] = useState<TotalKind>("exact");
  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 request is `loading`; every
  // radio press after it is `stale` — the rows stay up, untouched, while the
  // new count is in flight.
  const hasCommitted = useRef(false);

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

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

    fetchRows(EMPTY_QUERY, { totalKind }).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;
        setDataState({
          phase: "error",
          message: error instanceof Error ? error.message : "Request failed",
        });
      },
    );

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

  // The function the CSV export calls, given what this grid could give it:
  // how many records are loaded, what the server claims exists, and who has
  // authority over which records those are.
  const scope = resolveDataScope(
    { loadedRowCount: rows.length, matchingTotal: total },
    PROCESSING,
  );

  const [gridRef, ariaRowCount] = useAnnouncedRowCount();

  return (
    <div>
      <fieldset
        style={{
          border: 0,
          display: "flex",
          flexWrap: "wrap",
          gap: 12,
          margin: "0 0 8px",
          padding: 0,
        }}
      >
        <legend style={{ fontSize: 13, padding: 0 }}>
          The count the server claims
        </legend>
        {KINDS.map((kind) => (
          <label key={kind} style={{ fontSize: 13 }}>
            <input
              checked={totalKind === kind}
              name="server-totals-kind"
              onChange={() => setTotalKind(kind)}
              type="radio"
              value={kind}
            />{" "}
            {kind}
          </label>
        ))}
      </fieldset>
      <p role="status" style={{ margin: "0 0 8px", fontSize: 13 }}>
        Reported:{" "}
        <code data-testid="reported-total">{JSON.stringify(total)}</code> — an
        export of this grid would be scoped{" "}
        <code data-testid="export-scope">{scope}</code>, and the grid announces{" "}
        <code>aria-rowcount</code>{" "}
        <code data-testid="aria-rowcount">{ariaRowCount ?? "…"}</code>. The same
        480 records every time: only the claim about them changed.
      </p>
      <div ref={gridRef}>
        <PretableSurface<Order>
          ariaLabel="Orders"
          columns={columns}
          dataState={dataState}
          getRowId={(row) => row.id}
          // Fixed for the life of this grid, and the reason the funnels are
          // switched off: under external filter authority the engine stops
          // applying `query.filters`, so a funnel here would set one that did
          // nothing. What the claim changes on this page is what the grid is
          // willing to SAY about how many records there are.
          processing={PROCESSING}
          resultMeta={{ total, datasetKey: DATASET_KEY }}
          rows={rows}
          viewportHeight={VIEWPORT_HEIGHT}
        />
      </div>
    </div>
  );
}

/**
 * Reads the `aria-rowcount` the grid actually published, rather than
 * recomputing what it ought to be — the point of the readout is that it is the
 * attribute a screen reader gets.
 *
 * A `MutationObserver` rather than an effect keyed on the total, because the
 * attribute settles a beat after the rows commit: the row model ingests new
 * rows asynchronously, so the count is derived twice per change and only the
 * second value is the final one.
 */
function useAnnouncedRowCount(): [
  React.RefObject<HTMLDivElement | null>,
  string | null,
] {
  const ref = useRef<HTMLDivElement>(null);
  const [rowCount, setRowCount] = useState<string | null>(null);

  useEffect(() => {
    const viewport = ref.current?.querySelector(
      "[data-pretable-scroll-viewport]",
    );
    if (!viewport) return;

    const read = (): void =>
      setRowCount(viewport.getAttribute("aria-rowcount"));

    read();
    const observer = new MutationObserver(read);
    observer.observe(viewport, { attributeFilter: ["aria-rowcount"] });

    return () => observer.disconnect();
  }, []);

  return [ref, rowCount];
}
```

```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 for `isAnyOf`.
 *
 * Filtering and sorting are switched off in this grid, and only in this one.
 * Its query never leaves the client — the radio buttons change the TOTAL, not
 * the request's query — so a funnel here would filter locally while
 * `processing` claims the server chose the records, and an exact total would
 * go on announcing 481 rows over a body showing thirty. That is precisely the
 * dishonesty this page is about, so the example does not offer it. A real
 * server-filtered grid puts the query on the wire and leaves both on — that is
 * the grid on the section overview.
 */
export const columns: PretableColumn<Order>[] = [
  {
    id: "customer",
    header: "Customer",
    widthPx: 150,
    filterable: false,
    sortable: false,
  },
  {
    id: "region",
    header: "Region",
    type: "enum",
    widthPx: 90,
    filterable: false,
    sortable: false,
    options: [
      { value: "North" },
      { value: "South" },
      { value: "East" },
      { value: "West" },
    ],
  },
  {
    id: "status",
    header: "Status",
    type: "enum",
    widthPx: 100,
    filterable: false,
    sortable: false,
    options: [
      { value: "open" },
      { value: "shipped" },
      { value: "delivered" },
      { value: "cancelled" },
    ],
  },
  {
    id: "total",
    header: "Total",
    type: "number",
    widthPx: 90,
    filterable: false,
    sortable: false,
  },
  {
    id: "placedAt",
    header: "Placed",
    type: "date",
    widthPx: 100,
    filterable: false,
    sortable: false,
  },
];
```

```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 shape the surface speaks, 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.
 */
export type OrderQuery = PretableQueryFor<PretableSurfaceQueryColumns<Order>>;

/** How sure the endpoint should claim to be about the count. */
export type TotalKind = "exact" | "estimate" | "unknown";

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

/**
 * Sends the query and receives rows plus a description of them. Note what is
 * NOT sent: no `limit`, so every matching record comes back and the loaded
 * records really are the whole population — which is the only situation where
 * an exact total can honestly say so.
 */
export async function fetchRows(
  query: OrderQuery,
  options: { totalKind: TotalKind },
): Promise<RowsResponse> {
  const response = await fetch("/api/docs/rows", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ query, totalKind: options.totalKind }),
  });

  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;
}
```


## The three shapes

| `kind`     | Also carries | Means                                                                                                              |
| ---------- | ------------ | ------------------------------------------------------------------------------------------------------------------ |
| `exact`    | `count`      | The server counted the matching records and stands behind the number. The only claim the grid will act on.         |
| `estimate` | `count`      | Roughly this many. A planner's row estimate, a sampled count, a capped scan — a number to print, not one to trust. |
| `unknown`  | `atLeast`    | The count is not available. `atLeast` is optional, and belongs there only when the response itself proves it.      |

`atLeast` is the shape's one piece of care. A response that delivered 200 rows starting at offset 0 has proved that at least 200 records match, and nothing beyond that; the endpoint in these examples reports exactly `offset + deliveredCount` for that reason. What it must not be is a floor you wish were true. If you have no evidence, leave it off — `{ kind: "unknown" }` is a complete value.

What the three do to the **announced** count is the visible difference, and you can watch it in the readout above. `aria-rowcount` is the row count of the whole table, rows not in the DOM included, and ARIA defines `-1` as its "unknown". An exact total under full external authority — both `processing` slices external, no grouping — is published as `count + 1`, the header row being the `+ 1`, so the example announces **481**. An estimate cannot be spoken through an attribute whose contract is an integer, so it announces **-1**, and so does `unknown`; the number, if you have one, belongs in prose you write. Those conditions are the subject of [Query ownership](/docs/server-data/query-ownership#processing-authority), and short of them the grid publishes the one count it can prove — the rows in its own model.

What none of the three do is change the **scroll extent**. A grid scrolls over the rows it holds, and a total is a statement about a population, not a promise of scrollable space: claiming 10,000 matches while handing over 200 rows scrolls 200 rows, exactly as it did before you sent a total at all.

## What a total is allowed to claim

The rule is one sentence: a server that does not know the count says `unknown`, rather than guessing an `exact`.

That sounds obvious and is what most often goes wrong, because an exact count is expensive and a plausible number is right there. `SELECT COUNT(*)` over a large filtered join is a second query and often the slow one; planner estimates are free; a scan capped at 1,000 knows only "at least 1,000". Each of those has an honest shape here — `estimate` for the planner's figure, `unknown` with an `atLeast` for the capped scan — and reaching for `exact` because it is the field with the useful behavior is how a grid ends up announcing a number nobody counted.

The asymmetry that makes this worth stating is that the three kinds do not fail alike.

**An `estimate` that over-claims costs nothing.** The endpoint here rounds its estimate deliberately **up**, past the true count, because that is what real query planners do — and the grid is built so it cannot matter. `resolveDataScope` gates its `"all"` answer on `kind === "exact"`, and `aria-rowcount` publishes `-1` for anything else, so an estimate is never the input to a decision. It is a number you may print in your own footer and nothing more. The engine does not round it, correct it, or compare it to the rows.

**A false `exact` is a different thing, because `exact` unlocks.** It is the one kind that makes the grid say something it otherwise refuses to say, so a wrong one propagates instead of sitting there. An exact count **larger** than the truth is published verbatim, so `aria-rowcount` tells a screen-reader user there are ten thousand rows in a table that stops at 480, and every row position is announced against that figure. An exact count **smaller** than the records already loaded fails in the other direction: `resolveDataScope` reads it as "you hold every matching record already" and answers `"all"`, which is how a window onto a larger result comes to be exported under a heading saying it is the whole thing.

The grid catches exactly one case of that, and only because the case contradicts itself: an exact total claiming fewer records than the grid has loaded cannot describe those rows at all. The count is refused for `aria-rowcount` — the loaded-model count goes out instead — and a warning says so once, in production builds too. That is a floor, not a verification. A total wrong in any way the loaded rows do not contradict is published as given, because there is nothing there to contradict it with.

Both halves of that comparison are read from the same commit, so committing rows and their total together — which is what the props ask for — never trips it. Filtering the overview grid to one region replaces 480 rows and an exact 480 with 120 and an exact 120, and announces 121 with nothing in the console. If you do see the warning, it means what it says: the total and the rows cannot both be right.

## Exporting under external authority

The other half of what a total buys is what the grid will call "all rows", and that is a question with one answer per grid rather than one per feature: every user-facing count label and every CSV file routes through `resolveDataScope`.

```ts
import { resolveDataScope, type DataHonestyInput } from "@pretable/react";

const honesty: DataHonestyInput = {
  visibleRowCount: rows.length,
  isGrouped: false,
  loadedRowCount: rows.length,
  matchingTotal: total,
};

const scope = resolveDataScope(honesty, { filter: "external" });
```

It reads `processing.filter` and the total, and nothing else — sort authority does not enter into it. Under engine filter authority the loaded records _are_ the population, so the answer is `"all"`. Under external filtering it takes evidence: an `exact` total counting no more records than the grid has loaded. Everything else, including every `estimate` however close it is, is `"loaded"`.

That is why the example above flips as it does. The same 480 rows are on screen in all three states; only `exact` produces `"all"`, because only `exact` proves that 480 is the whole of it. Add a `limit` to that request and even `exact` becomes `"loaded"` — which is the ordinary case for a paged remote grid, and the reason the helper is public at all.

A `"loaded"` scope is not a failure state. It is what puts the `unloaded-rows` omission into a serialized file and `-PARTIAL` into its filename, so the person who pressed the button learns that the file is a window. Hardcoding `"all"` does not make an export complete; it only stops it saying that it is not. The full picture — `serializeCsv`, the omissions list, and the `exportCsv` handle that resolves the scope for you — is on [CSV export](/docs/grid/export), which links back here for this half of it, as does [Clipboard](/docs/grid/clipboard) for the group aggregates it scopes the same way.

Those aggregates have a second thing to know about them under this authority, and it is about the number rather than the scope label. Declaring external filtering stops the engine applying `query.filters` at all, so there is no post-filter subset left for a group to fold — [aggregates and child counts cover every loaded row](/docs/server-data/query-ownership#what-suppression-does-not-cover), whichever way `aggregateFilteredRows` is set. Correct, when the server is the one that chose the records; different, from what the same grid reports under engine filtering.

## See also

- [Server-side data](/docs/server-data) — the section overview, its endpoint, and what the grid keeps owning.
- [Query ownership](/docs/server-data/query-ownership) — the `processing` claim, the filtering it suppresses, and the three things it changes what the grid says about.
- [CSV export](/docs/grid/export) — `serializeCsv`, the scope argument, and what a partial file says about itself.
