# Windowing

resultMeta.window says where the loaded rows sit inside the result, telemetry.windowGap says when the reader has run off the end of them, and one rule keeps the positions the grid announces from ever contradicting the count.


Every other grid in this section holds the whole answer: 480 records come back, 480 records go in, and the first row you passed is the first row that matched. A result you cannot hold breaks that assumption quietly. The rows are still an array starting at index 0, and they are no longer rows 0 to 99 of anything — they are somebody's hundred, out of somebody else's four hundred and eighty, and nothing in the array says so.

`resultMeta.window` is where you say so. It is two numbers you already have — where you asked from, and whether the response left anything behind — and handing them over is what lets the grid describe the population rather than its own memory: a scrollbar the size of the result, and a position on every row that means what a person would think it means.

The grid below loads one block of a hundred orders at a time and scrolls over all 480. Scroll to the bottom of the loaded block and keep going: the grid reports the gap, the next block is fetched with the endpoint's usual 500&nbsp;ms pause, and the block it replaces is dropped. Watch the readout while you do it — the dataset position climbs, the number of rows fetched climbs, and the number of rows in memory does not.

### Example: A hundred rows at a time, out of four hundred and eighty

The grid holds one block of 100 orders and scrolls over all 480. When the viewport runs off the loaded window the grid says so through telemetry, the next block is fetched, and the block it replaces is dropped — so the dataset position climbs while the row count in memory does not.

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

```tsx WindowedGrid.tsx
"use client";

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

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

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

/** Records held in memory at once, out of the endpoint's 480. */
const PAGE_SIZE = 100;

/**
 * How far the window slides when the viewport runs off an edge. Half a block,
 * not a whole one: the viewport that produced the signal is sitting AT the
 * edge it reached, and a full-block slide would drop the rows underneath it —
 * leaving the reader parked over spacer, waiting on a request. Half keeps what
 * they are looking at loaded, and still bounds memory at one block.
 */
const WINDOW_STEP = 50;

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

/**
 * One population, never re-queried, so one key for the life of the grid.
 *
 * It has to be here at all because interaction state is recorded against
 * dataset positions, and a position only means something inside a named
 * population — see the datasetKey section on the lifecycle page.
 */
const DATASET_KEY = "docs-orders";

const VIEWPORT_HEIGHT = 320;

/**
 * The three facts that have to move together. A render that paired one block's
 * rows with the other block's `start` would announce every row at the wrong
 * dataset position, so they live in one state value and commit in one update.
 */
interface LoadedWindow {
  readonly start: number;
  readonly hasMore: boolean;
  readonly rows: Order[];
}

const NOTHING_LOADED: LoadedWindow = { start: 0, hasMore: false, rows: [] };

export function WindowedGrid() {
  const [requestedStart, setRequestedStart] = useState(0);
  const [loaded, setLoaded] = useState<LoadedWindow>(NOTHING_LOADED);
  const [total, setTotal] = useState<PretableMatchingTotal>({
    kind: "unknown",
  });
  const [fetchedRows, setFetchedRows] = useState(0);
  const [dataState, setDataState] = useState<PretableDataState>({
    phase: "loading",
  });

  // Whether a block has ever committed: the first request is `loading`,
  // because there is nothing on screen yet.
  const hasCommitted = useRef(false);
  // One request at a time. `windowGap` keeps reporting for as long as the
  // viewport is past the edge, which over the endpoint's 500 ms is many
  // frames' worth of telemetry describing one gap.
  const inFlight = useRef(false);
  // What has been ASKED for, which leads what is loaded by one request. Kept
  // in a ref as well as in state because telemetry can fire several times
  // inside a single commit, and a `setState` updater reading the value React
  // has not re-rendered with yet would step the window once per call — two
  // blocks skipped for one gap.
  const requested = useRef(0);

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

    inFlight.current = true;
    // The same query, more of the same population, and everything on screen
    // stays exactly where it is until the answer arrives — which is the phase
    // `loading-more` names. Nothing is drawn for it while rows are up.
    setDataState({ phase: hasCommitted.current ? "loading-more" : "loading" });

    fetchWindow(requestedStart, PAGE_SIZE).then(
      (result) => {
        if (cancelled) return;
        inFlight.current = false;
        hasCommitted.current = true;
        // Eviction, in one line: the block that was on screen is not merged
        // with this one, appended to, or kept beside it. It is dropped, and
        // the only thing that remembers it existed is the fetched-rows
        // counter below. There is no API for this — dropping rows you are not
        // showing is a thing you do, and the grid's contribution is not
        // noticing.
        setLoaded({
          start: result.start,
          hasMore: result.hasMore,
          rows: result.rows,
        });
        setTotal(result.total);
        setFetchedRows((count) => count + result.rows.length);
        setDataState({ phase: "idle" });
      },
      (error: unknown) => {
        if (cancelled) return;
        inFlight.current = false;
        hasCommitted.current = true;
        // Rows are left untouched: a failed request never discards the block
        // that did answer.
        setDataState({
          phase: "error",
          message: error instanceof Error ? error.message : "Request failed",
        });
      },
    );

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

  const onTelemetryChange = useCallback(
    (telemetry: PretableTelemetry<string>) => {
      const gap = telemetry.windowGap;
      // A hot path: this runs on scroll frames. `windowGap` is absent unless
      // the viewport is genuinely over rows that were never supplied, which is
      // why the grid computes it — it owns the geometry, and a consumer
      // thresholding a scroll offset would be reconstructing what is already
      // known.
      if (gap === undefined || inFlight.current) return;
      const next =
        gap.direction === "after"
          ? requested.current + WINDOW_STEP
          : Math.max(0, requested.current - WINDOW_STEP);
      // Standing at the top of the population: the "before" edge is the first
      // record, and there is nowhere to step back to.
      if (next === requested.current) return;
      // Both refs move before the state does, so a second report arriving in
      // the same commit finds the decision already made.
      requested.current = next;
      inFlight.current = true;
      setRequestedStart(next);
    },
    [],
  );

  const resultMeta = useMemo<PretableResultMeta>(
    () => ({
      total,
      datasetKey: DATASET_KEY,
      // `start` is the dataset index of `rows[0]` — the only thing telling the
      // grid these hundred records are not records 0–99. `hasMore` gates the
      // "after" edge: once nothing follows, there is nothing to fetch, and no
      // signal is reported.
      window: { start: loaded.start, hasMore: loaded.hasMore },
    }),
    [loaded.hasMore, loaded.start, total],
  );

  return (
    <div>
      <p role="status" style={{ margin: "0 0 8px", fontSize: 13 }}>
        Window starts at dataset row{" "}
        <code data-testid="window-start">{loaded.start}</code> ·{" "}
        <code data-testid="loaded-rows">{loaded.rows.length}</code> rows loaded
        · <code data-testid="fetched-rows">{fetchedRows}</code> rows fetched
        since this grid mounted. Scroll to the bottom and keep going: the
        position climbs and the fetch count climbs, while what is in memory does
        not.
      </p>
      <PretableSurface<Order>
        ariaLabel="Orders"
        columns={columns}
        dataState={dataState}
        getRowId={(row) => row.id}
        onTelemetryChange={onTelemetryChange}
        // The server chose these records and their order. Under a window that
        // claim is load-bearing rather than cosmetic: short of it, the grid
        // will not publish dataset positions at all, because a locally
        // re-filtered or re-sorted window has none to publish.
        processing={PROCESSING}
        resultMeta={resultMeta}
        rows={loaded.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 — never more obviously than here — so derived values
 * would offer whichever hundred rows happened to be loaded as if they were the
 * complete universe for `isAnyOf`.
 *
 * Filtering and sorting are switched off, as they are on the totals page and
 * for a related reason: a filter or a sort redefines the population, and every
 * dataset position in it. That is a new `datasetKey`, a window reset to zero,
 * and a second story competing with this page's one. The section overview is
 * the grid that puts a query on the wire; this one holds the population still
 * so that the only thing moving is the window.
 */
export const columns: PretableColumn<Order>[] = [
  {
    id: "id",
    header: "Order",
    widthPx: 100,
    filterable: false,
    sortable: false,
  },
  {
    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,
  },
];
```

```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 speaks, typed once.
 *
 * 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 forms compile, so the wrong
 * one is a silent `never` rather than a type error.
 */
export type OrderQuery = PretableQueryFor<PretableSurfaceQueryColumns<Order>>;

/**
 * This grid's query never changes — see `columns.ts` for why it is the one
 * grid in this section with its funnels and header sorts switched off. One
 * population, held still, so the only thing moving is the window over it.
 */
export const EMPTY_QUERY: OrderQuery = { filters: [], sort: [], rowGroups: [] };

export interface WindowResponse {
  /** Dataset index of `rows[0]` — what `resultMeta.window.start` publishes. */
  start: number;
  rows: Order[];
  total: PretableMatchingTotal;
  /** Whether anything follows this window. Not how much. */
  hasMore: boolean;
}

interface RawResponse {
  rows: Order[];
  total: PretableMatchingTotal;
}

/**
 * One block of `limit` records beginning at dataset index `start`.
 *
 * The response describes the population (`total`) and the client already knows
 * where it asked from, so both halves of `resultMeta.window` are things this
 * layer holds before the grid ever sees them. `hasMore` is derived from the
 * exact count here; against a keyset cursor it would be whatever the cursor
 * says about a next page, which is why the field promises existence rather
 * than a remaining count.
 */
export async function fetchWindow(
  start: number,
  limit: number,
): Promise<WindowResponse> {
  const response = await fetch("/api/docs/rows", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      query: EMPTY_QUERY,
      offset: start,
      limit,
      totalKind: "exact",
    }),
  });

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

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

  const { rows, total } = (await response.json()) as RawResponse;

  return {
    start,
    rows,
    total,
    hasMore:
      total.kind === "exact"
        ? start + rows.length < total.count
        : rows.length === limit,
  };
}
```


## The window

```tsx
<PretableSurface
  ariaLabel="Orders"
  columns={columns}
  processing={{ filter: "external", sort: "external" }}
  resultMeta={{
    total: { kind: "exact", count: 480 },
    datasetKey: "orders",
    window: { start: 100, hasMore: true },
  }}
  rows={rows}
/>
```

`start` is the dataset index of `rows[0]` — zero-based, and measured in the population the total counts. The window above says that the first row of the array is the 101st record of 480, so the hundred rows in memory are records 100 through 199.

Leaving `window` off, or setting `start: 0`, is the ordinary prefix case: the rows begin where the result begins, which is what every un-windowed grid has always claimed by saying nothing.

`hasMore` is whether anything follows this window. **Not how much.** That is deliberate, and the reason is the kind of backend a window is usually served from: a keyset cursor walks forward and knows only whether it can walk again. A remaining count would invite a scroll extent reaching rows the cursor cannot serve, so the field promises existence and stops there. What it buys is the one thing that has to be true for the near-edge signal below to be worth anything — a grid that knows there is nothing after the last loaded row will not ask you to fetch it.

Both values are facts you already hold before the grid sees them. You chose the offset; the response told you whether a next page exists. Neither is something the grid could work out, and — as with [the total](/docs/server-data/totals) — neither is something it can check.

## What the grid does with it

Two things change, and they are the same claim seen from two sides.

**The scroll extent describes the population.** The unmaterialized regions are reserved as spacers — `start` rows ahead of the window, and whatever the total says follows its end behind it — so the scrollbar measures 480 rows while a hundred are in memory, and a reader dragging it is moving through the result rather than through your cache. A total on its own never does this: as [Totals and honesty](/docs/server-data/totals) says, a grid with 200 rows and a claimed 10,000 scrolls 200 rows. The window is the part that says where the other rows would be.

**Every row reports its dataset position.** `aria-rowindex` on a body row counts from the population, not from the array: with the window above, the row holding record 100 publishes `aria-rowindex="102"` — one for a zero-based index becoming ARIA's one-based one, and one for the header row, which is always row 1.

Those two are gated together, by one rule:

> A row reports a dataset position only when the grid is also reporting the dataset count.

The offset, the spacers, and the dataset spans a selection is recorded against all ride one boolean — whether `aria-rowcount` published the population's count rather than falling back — so the parts can never contradict each other. It resolves true when:

- `resultMeta.window` is present;
- both `processing` slices are `"external"` — a locally filtered or locally sorted window has no dataset positions to publish, because the engine, not the server, chose which of its rows survived and in what order;
- nothing is grouped, since group headers and collapsed branches break the one-row-one-position mapping;
- `resultMeta.total` is `{ kind: "exact" }` with an integer count;
- and that count is at least `window.start` plus the number of loaded rows — a window has to fit inside the population it claims to be a window onto.

Short of any of them the grid degrades rather than guesses: `aria-rowcount` publishes the loaded-model count (or `-1`, ARIA's "unknown", for a total that is not exact), positions are announced from the top of the loaded rows, and no spacers are drawn. The scroll extent goes back to being the rows you passed.

Two of those failures are loud, because they are contradictions rather than absences. A `count` that is not an integer cannot be published through the attribute at all, and a count below `window.start + loaded` describes a result the loaded rows cannot be a window of. Each warns once — in production builds too — and reports the loaded-model count instead. The rest are silent by design: an estimate instead of an exact total is a legitimate thing to send, and it means only that the grid has nothing to position against.

## Knowing when to fetch

The grid does not fetch, and under a window it is nonetheless the only thing that knows when you should. It owns the geometry — which rows the viewport is over, in a coordinate space that includes the spacers — so it reports the moment the reader runs off the loaded block, through `telemetry.windowGap`:

| Field       | Type                  | Means                                                                                                                                                                                               |
| ----------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `direction` | `"before" \| "after"` | Which edge the viewport reached. `"before"` is the region ahead of `window.start`; `"after"` is past the last loaded row, and is only ever reported while `window.hasMore` is `true`.               |
| `rowCount`  | `number`              | How many rows the population claims on that side of the loaded window — everything before `start`, or everything the total says follows its end. A size, not the viewport's distance past the edge. |

The field is optional and absent whenever the viewport is over loaded rows, which is almost always: absence is the ordinary state, not a failure. It rides the same gate as the positions above — an untrustworthy window has no honest gap to report either — so a grid that is not publishing dataset positions never reports one.

Three things follow for the handler you write.

It is a hot path. `onTelemetryChange` fires as the viewport moves, and a gap is reported for as long as the reader is standing past the edge, which over a 500&nbsp;ms request is a great many calls describing one gap. Keep the callback stable with `useCallback` and keep your own record of what is in flight; the grid cannot debounce, cancel, or retry for you here any more than it can anywhere else. Several reports can also land inside one commit, so decide from a ref rather than from a state updater — a `setState` that reads the value React has not re-rendered with yet steps the window once per call.

It tells you where the reader is, not what to load. `rowCount` is the size of the unloaded region, so it is a bound rather than an instruction — the example above ignores it and slides half a block per signal, which keeps the rows under the reader loaded while the fetch is out. A pager that jumped the full block would drop what they were looking at and leave them on empty spacer for half a second.

And nothing in it evicts. Fetching the next block and releasing the previous one are two decisions, and only the first is prompted; see [Eviction](/docs/server-data/eviction) for what the grid guarantees about the second.

## What an absent gap means

A gap is reported from the geometry the grid has actually drawn: where the leading spacer ends, and where the loaded rows end. Both come from the same layout pass, so a `resultMeta` that changes on its own — a `total` refined downward while the rows and the viewport hold still — is reflected in the very next report rather than on the next scroll. The counts a gap carries are read fresh from `resultMeta`; the pixels it is judged against are the plan's own. Neither is reconstructed from the other.

The one thing that follows is that a grid whose layout does not yet describe its rows reports nothing at all. At mount that covers a few renders: before the first layout pass, and again while a block you have just committed has not been laid out. Rather than guess from a height of zero — which would put the end of the window at a negative pixel and make every viewport, scrolled or not, read as past it — the grid stays silent until the layout and the rows agree. So absence is "no signal", never proof that the viewport is inside the window. If your own state can tell that the reader is past the loaded block, act on what you know.

One thing genuinely does wait for a replan, and it is the scroll extent rather than the signal: the spacers are sized by the layout pass, so a `total` that changes without touching the rows or the viewport leaves the scrollbar describing the previous total until the next scroll or row change resizes it. What `windowGap` tells you is correct throughout.

## Re-opening a window you have seen

Nothing above helps a reader scroll back. The `"before"` direction says they went that way; what to send the server is a question the grid has no part in, and the answer is a contract rather than a feature: **keep the cursor that opened each block, and re-send it.** A keyset continuation is a position, not a session, so replaying the cursor that produced block 2 returns block 2 — walking past it and back does not consume it.

That leaves a stack of cursors, one per block you have opened, and two constraints on it that were found by trying rather than by reasoning:

- **A cursor's fingerprint includes the moment it was stamped.** Re-stamping rejects every stored cursor at once rather than one at a time, and the stack has to be rebuilt from the head. A windowed session pins a single instant and accepts that expiry is judged as of then.
- **A `datasetKey` change discards the whole stack.** A new identity means the population is different, so every offset measured in the old one is meaningless — including the ones your cursors encode. The grid resets to the top for the same reason, and [that is what the key is for](/docs/server-data/lifecycle#datasetkey).

## See also

- [Eviction](/docs/server-data/eviction) — dropping the rows you are not showing, and what the grid promises survives it.
- [Totals and honesty](/docs/server-data/totals) — `resultMeta.total`, the exact claim a window's positions depend on, and what an export of a window may call itself.
- [Loading, staleness, errors](/docs/server-data/lifecycle) — the phase a block fetch is in while it is out, and the `datasetKey` a window is measured against.
