---
id: "packages/active-grid/server-mode"
title: "Server mode"
description: "How server mode works: the fetch function, blocks, the request parameters, mutations and the patterns to avoid."
url: "/packages/active-grid/server-mode"
locale: "en"
revision: "cac8e473655be8df"
---

# Server mode

> [!NOTE]
> **The package's own guide**
>
> This guide is the package's `docs/guides/server-mode.md`, included unchanged
> from the package as it is in Foundation. Links to the package's internal
> design documents keep their text.

**Example: Server mode against an imitated server**

Simulation: A server imitated in your browser: shows the interaction, not a working server integration. Source: `examples/grid/simulated-orders.tsx`.

```tsx
// apps/docs/examples/grid/simulated-orders.tsx
'use client';

import '@workspace/active-grid/styles.css';

import {useCallback, useMemo, useState} from 'react';
import {generateOrders, queryOrders, type Order} from '@/lib/examples/orders';

import {
  composePresentationContext,
  toIanaTimeZone,
} from '@workspace/active-format';
import {
  ActiveGrid,
  createColumnHelper,
  englishGridTranslator,
  scalarField,
  type GridColumnDef,
  type ServerFetchFunction,
} from '@workspace/active-grid';
import {
  Field,
  FieldLabel,
} from '@workspace/active-ui/components/primitives/field';
import {Switch} from '@workspace/active-ui/components/primitives/switch';

const presentation = composePresentationContext(
  'en-GB',
  null,
  toIanaTimeZone('Europe/Amsterdam'),
);

const ch = createColumnHelper<Order>();
const columns: GridColumnDef<Order>[] = [
  ch.accessor('id', {
    header: 'Order',
    meta: {filterType: 'text'},
    server: scalarField(),
  }),
  ch.accessor('product', {
    header: 'Product',
    meta: {filterType: 'text'},
    server: scalarField(),
  }),
  ch.accessor('quantity', {
    header: 'Qty',
    type: 'number',
    server: scalarField(),
  }),
  ch.accessor('total', {
    header: 'Total',
    type: 'currency',
    meta: {currencyCode: 'EUR', filterType: 'number'},
    server: scalarField(),
  }),
];

const wait = (ms: number, signal: AbortSignal) =>
  new Promise<void>((resolve, reject) => {
    const timer = setTimeout(resolve, ms);
    signal.addEventListener('abort', () => {
      clearTimeout(timer);
      reject(signal.reason);
    });
  });

/**
 * Server mode against a server imitated in the browser: a fetchFn with a
 * delay over rows that are already here, and a switch that makes it fail.
 * It shows the grid's loading, paging and error states, not a server.
 */
export default function SimulatedOrders() {
  const orders = useMemo(() => generateOrders(2000), []);
  const [failing, setFailing] = useState(false);
  const fetchOrders = useCallback<ServerFetchFunction<Order>>(
    async ({signal, ...params}) => {
      await wait(600, signal);
      if (failing) throw new Error('The simulated server is down');
      return queryOrders(orders, params);
    },
    [orders, failing],
  );
  return (
    <div className="flex flex-col gap-4">
      <Field orientation="horizontal">
        <Switch
          id="simulated-failure"
          // Named in the server HTML already, not only after hydration.
          aria-labelledby="simulated-failure-label"
          checked={failing}
          onCheckedChange={setFailing}
        />
        <FieldLabel id="simulated-failure-label" htmlFor="simulated-failure">
          Make the next request fail
        </FieldLabel>
      </Field>
      <ActiveGrid<Order>
        key={String(failing)}
        gridId="docs-simulated-orders"
        mode="server"
        fetchFn={fetchOrders}
        columns={columns}
        height={360}
        locale={presentation}
        t={englishGridTranslator}
        pagination={{enabled: true}}
        serverOptions={{blockSize: 25, retryCount: 0}}
      />
    </div>
  );
}
```

Active Grid's server mode delegates sorting, filtering, and pagination to
the server. The grid sends request parameters and renders whatever the
server returns.

For API reference see `../api/server-mode.md`.

## fetchFn Interface

Server mode requires a `fetchFn` prop — an async function that receives
grid state and returns paginated data:

```typescript
import {
  type ServerFetchFunction,
  type ServerFetchParams,
  type PagedResponse,
} from '@workspace/active-grid';

type User = {id: string; firstName: string; email: string};

const fetchUsers: ServerFetchFunction<User> = async (
  params: ServerFetchParams,
) => {
  // params contains:
  // - pageIndex: number (zero-based)
  // - fetchSize: number (engine's fetch-block size, not UI page size)
  // - sorting: Array<{id: string, desc: boolean}>
  // - filters: Array<{id: string, value: FilterValue}>
  // - globalFilter: string | undefined
  // - signal: AbortSignal (for cancelling in-flight requests)

  const response = await fetch('/api/records', {
    method: 'POST',
    body: JSON.stringify(params),
    signal: params.signal,
  });

  const json = await response.json();

  return {
    data: json.items,
    totalCount: json.total,
    facets: json.facets, // optional
    aggregations: json.totals, // optional
  };
};
```

## Request Parameters

### ServerFetchParams

| Parameter | Type | Description |
| --- | --- | --- |
| `pageIndex` | `number` | Zero-based fetch-block index. First block is `0`. |
| `fetchSize` | `number` | Number of rows the engine wants in this fetch (a **fetch-block** size, not the UI page size). Pass through to your Prisma `take`. |
| `sorting` | `SortingState` | Array of `{id: string, desc: boolean}`. Empty array means no sorting. |
| `filters` | `ColumnFiltersState` | Array of `{id: string, value: FilterValue}`. Each value is a discriminated union. |
| `globalFilter` | `string \| undefined` | Global search term from the toolbar search input. |
| `signal` | `AbortSignal` | Signal for cancelling in-flight requests. Aborted when a newer fetch starts. Forward to `fetch()` or use for server-side cancellation. |

### Filter Values in Request

Column filter values arrive as `FilterValue` objects — discriminated unions
based on the operator:

```typescript
// Text filter: "contains 'john'"
{id: 'name', value: {operator: 'contains', value: 'john'}}

// Number filter: "greater than 50000"
{id: 'salary', value: {operator: 'greaterThan', value: 50000}}

// Date filter: "between 2025-01-01 and 2025-12-31"
{id: 'startDate', value: {operator: 'inRange', value: '2025-01-01', valueTo: '2025-12-31'}}

// No-value filter: "is empty"
{id: 'email', value: {operator: 'isEmpty'}}

// Select filter: array of selected values
{id: 'department', value: ['engineering', 'marketing']}
```

## Response Format

### PagedResponse

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | `TData[]` | Yes | Row data for the current page. |
| `totalCount` | `number` | Yes | Total rows across all pages (for pagination calculation). |
| `facets` | `Record<string, FacetOption[]>` | No | Server-calculated filter options per column. |
| `aggregations` | `Record<string, number \| string>` | No | Server-calculated aggregation values per column. |

### Facets

Facets provide the options for select filters. When returned, they replace
client-side facet computation:

```typescript
return {
  data: users,
  totalCount: 1234,
  facets: {
    department: [
      {label: 'Engineering', value: 'engineering', count: 450},
      {label: 'Marketing', value: 'marketing', count: 230},
      {label: 'Sales', value: 'sales', count: 554},
    ],
    status: [
      {label: 'Active', value: 'active', count: 1100},
      {label: 'Inactive', value: 'inactive', count: 134},
    ],
  },
};
```

### Aggregations

Aggregations provide footer values (sum, average, count, etc.) calculated
server-side:

```typescript
return {
  data: orders,
  totalCount: 5000,
  aggregations: {
    totalAmount: 1250000,
    quantity: 45000,
  },
};
```

The column's `meta.aggregationLabel` is prepended when rendering.

## Getting rows: the resource pipeline

`active-grid` no longer builds or resolves server-side queries, and there is
no `/server` subpath. Every server-mode grid obtains its rows from a **resource
view** defined in `@workspace/active-resources`, wired to the grid with
`createResourceServerFetchFn`. The browser sends only the server-defined view
key plus the interaction state; the model, permission, `select` and every
`where` fragment live server-side in the resource declaration.

```tsx
import {
  ActiveGrid,
  createResourceServerFetchFn,
} from '@workspace/active-grid';

const fetchFn = useMemo(
  () =>
    createResourceServerFetchFn<RecordRow>({
      serverAction: getRecordsResourceAction, // active-resources data-action
      viewKey: 'records.list',
      scope: (params) => ({datasetId}), // client-supplied, server-verified
      transform: (row) => toGridItem(row),
    }),
  [datasetId],
);

<ActiveGrid mode="server" fetchFn={fetchFn} ... />;
```

See `../api/resource-mode.md` for the full
`CreateResourceServerFetchFnConfig` surface and the `serverCapabilities` prop.

A source that cannot be a resource view is a narrow, recorded exception with
server-side conditions; see
`../ARCHITECTURE.md`. It still
adapts its result to `ServerFetchFunction`. The transport fragments above
illustrate that function shape, not an additional default data-access
architecture.

## Mutating server-mode grids (`useGridMutation`)

The recommended consumer API for "run async mutation, then patch the grid"
is the `useGridMutation` hook. It runs an async action, applies a named
grid-strategy on success, and tracks `isPending` — all without remounting
the grid.

```tsx
import {
  ActiveGrid,
  type GridImperativeHandle,
  useGridMutation,
} from "@workspace/active-grid";

const gridRef = useRef<GridImperativeHandle>(null);
const mutation = useGridMutation(gridRef);

const onDelete = async (id: string) => {
  const result = await mutation.mutate(id, {
    action: (rowId) => softDeleteRecordAction(rowId, csrfToken),
    onSuccess: { type: "removeRow", rowId: id },
  });
  if (result.success) toast.success("Deleted");
  else toast.error(result.error.message);
};

<ActiveGrid gridRef={gridRef} mode="server" ... />;
```

After a bulk delete, compose strategies with an array:
`onSuccess: [{type: 'refetch'}, {type: 'resetSelection'}]`. The five
strategies, `isPending` and the typed result are listed in
`../api/server-mode.md` § useGridMutation.

**Never use `<ActiveGrid key={mutationCounter}>` to force a refresh** —
that remounts the whole grid and discards all session state.

### Imperative primitive (`gridRef` / `useGridImperative`)

The package still exports `GridImperativeHandle` and the typed
`useGridImperative<T>(ref)` wrapper. These are now considered the
**package-internal primitive** that `useGridMutation` is built on top of.
Production consumer-code must use `useGridMutation` so the strategy choice
is named, reviewable, and testable.

Legitimate exceptions where the imperative API may still be called
directly from consumer-code:

- Library-internal tests inside `@workspace/active-grid` itself.
- Demo / showcase pages that exercise the primitive directly without a
  preceding async action (e.g. an "Add row" button on a sample data page).
- Pure UI commands tied to a button — for instance a "Deselect" button
  that calls `gridRef.current?.resetSelection()` outside a mutation flow.

All other consumer-side usage should go through `useGridMutation`.

## Client-mode mutations

In `mode="client"` the grid reads the `data` prop directly each render and
`gridRef.removeRow`/`addRow` are no-ops (with a dev-time warning). Mutate
your own data state instead:

```tsx
const [data, setData] = useState(rows);

const onDelete = async (id: string) => {
  const result = await deleteAction(id);
  if (result.success) setData((prev) => prev.filter((r) => r.id !== id));
};

<ActiveGrid mode="client" data={data} ... />
```

## Top pitfalls (server mode)

1. **`maxFetchSize` \< block size** — the engine fetches data in *blocks*
   (default 200 rows), not in UI pages. If the resource view's row cap is
   below the block size, every fetch fails validation; raise the cap.
2. **`fetchSize` is the engine's fetch-block size, not the UI page size** —
   `ServerFetchParams.fetchSize` is the row-count the engine wants for
   *this server call*. The UI page size is independent and lives on
   `pagination.pageSizeOptions`; the engine collapses multiple UI pages
   into a single fetch block for cache efficiency. Pass `fetchSize`
   straight through to your Prisma `take` (or equivalent).
3. **`<ActiveGrid key={...}>` remount anti-pattern** — incrementing a
   `key` prop after each mutation destroys grid session state (filters,
   sort, column widths, scroll position, selection). Use
   `useGridMutation(gridRef)` — or, for ad-hoc cases,
   `gridRef.current?.removeRow(id)` / `.refetch()` — instead. A dev-time
   detector emits a warn-once log when the same `gridId` mounts twice
   outside the React-Strict-Mode noise window.
4. **Silent client-mode `removeRow`** — in `mode="client"` the imperative
   API is a no-op. Manage data via `setData(...)` and pass the new array
   as the `data` prop. The grid emits a dev warning if `removeRow`/`addRow`
   is called against a client-mode grid.
5. **`router.refresh()` after grid mutations** — `router.refresh()`
   invalidates the surrounding Next.js Router Cache but does not retrigger
   the grid's `fetchFn`. For server-mode grids, use `gridRef.refetch()`, or
   `gridRef.removeRow(id)` to drop a row the server has already deleted from
   the loaded rows without a refetch. That is a post-commit patch, not an
   optimistic update: it runs after the mutation succeeded and has no
   rollback. Optimistic updates with rollback do not exist (F-13 in
   `../ROADMAP.md`). Reserve `router.refresh()` for
   refreshing server-rendered shell elements outside the grid.
6. **A changed scope with an unchanged `fetchFn` identity** — the grid
   re-fetches on ITS own state (page, sort, filter, search, page size). It
   cannot see anything your `fetchFn` closes over: a resource `scope`, a
   preset, a tenant id, an external filter the page owns in the URL. The
   preferred signal is **`serverQueryKey`**, with a `fetchFn` memoised on the values the
   scope is built from. Memoised on `[]` while the scope moves, the grid
   keeps showing the previous answer with no error and no warning; re-created
   every render, it invalidates its cache every render. See
   `api/server-mode.md` § The external query.
7. **A route query key colliding with `enableUrlSync`** — the grid claims
   exactly `page`, `pageSize`, `sort`, `filters` and `search`, and merges
   rather than replaces (ADR-023). A page that owns a key of the same NAME
   shares it: `page` and `sort` are the realistic collisions. A route with its
   own pager, or its own sort grammar such as `sort=name-asc`, must not enable
   `enableUrlSync` on that grid — the grid would read `page=2` as its own page
   index and re-serialise `sort` in `id:asc` form. Either the route owns the
   URL or the grid does.
8. **Reading a stalled grid as a slow one** — a server-mode grid that never
   fetches renders exactly like one that is still fetching: `isLoading` starts
   `true`, the default first-load indicator is `'none'` (ADR-021), the body
   suppresses its empty state while loading and the footer suppresses its row
   count. If a grid stays blank, check the dev console for the idle-datasource
   warning below before hunting through props.

## Dev-time warnings

Active Grid ships a handful of dev-only diagnostics for common
anti-patterns. None of them run in production builds — the whole branch
collapses behind a `process.env.NODE_ENV !== "production"` guard so the
detector code tree-shakes out of the consumer bundle. Each one is a `warn`
diagnostic from source `ActiveGrid`, delivered to the
`<GridDefaults diagnostics>` sink (the console without one), once per ground.

| Warning | Trigger | Fix |
| --- | --- | --- |
| `maxFetchSize` below engine block size | The action's `maxFetchSize` is below the engine's default block size (200). | Raise `maxFetchSize` or lower the engine's `blockSize` via `serverOptions`. |
| Client-mode `addRow` / `removeRow` no-op | `gridRef.current?.addRow(...)` or `.removeRow(...)` called while the grid is in `mode="client"`. | Mutate your own `data` state. |
| `<ActiveGrid key={...}>` remount | The same `gridId` mounts twice outside the React-Strict-Mode noise window (i.e. the consumer changed `key={...}`). | Switch to `useGridMutation(gridRef)` or call `gridRef.current?.removeRow(id)` / `.refetch()`. |
| Client-mode `pagination.mode="infinite"` | `pagination={{mode: 'infinite'}}` on a `mode="client"` grid. The setting is ignored and standard pagination is used. | Remove `pagination.mode` or switch the grid to `mode="server"`. |
| `editMode` without its commit callback | `editMode="batch"` without `onBatchCommit`, or `"cell"`/`"row"` with only `onBatchCommit`. Editing is disabled entirely. | Supply the callback that matches the mode — see `editing.md`. |
| Idle server-mode datasource | Server mode, hydration done, no request in flight, no rows, no error — for several seconds. Names the grid and the blocking condition. | Follow the named condition: a missing `fetchFn`, state that never hydrated, or a `fetchFn` identity that never settled. |
| Inert `blockSize` | `serverOptions.blockSize` is smaller than the UI page size, so `pagesPerBlock` floors to 1 and every page change is a server call. Once per grid + blockSize + pageSize. | Raise `blockSize` to a multiple of the largest page size, or drop it and take the default (200). |

Note the difference between the two `blockSize` diagnostics. An **inert**
block size warns; an **invalid** one (`0`, negative, `NaN`, `Infinity`,
non-integer, non-number) throws a **GridConfigurationError** with code
**GRID\_SERVER\_OPTIONS\_INVALID** — see
`api/server-mode.md` § Valid ranges.

The remount detector fires once per `gridId` per session and only when the
second mount happens >100 ms after the first — that window comfortably
covers React-Strict-Mode's same-tick double-invocation while still
catching real consumer-driven remounts.

## Observing grid state

The grid publishes its accepted interaction state through `grid.getState()`
and `grid.subscribe(listener)`; a React component observes it with
`useSyncExternalStore`. The state object and each of its slices are replaced
only when a change is accepted, so a snapshot function that returns a slice or
a primitive re-renders exactly when that value changes. Never build a new
object inside the snapshot function: every read would then look like a change.

```tsx
// Good: a slice keeps its identity until a change to it is accepted.
const selection = React.useSyncExternalStore(
  grid.subscribe,
  () => grid.getState().rowSelection,
);
const selectedCount = Object.keys(selection).length;

// Good: a primitive compares by value.
const pageIndex = React.useSyncExternalStore(
  grid.subscribe,
  () => grid.getState().pagination.pageIndex,
);

// Bad: a new object on every read never settles.
// React.useSyncExternalStore(grid.subscribe, () => ({
//   count: Object.keys(grid.getState().rowSelection).length,
// }));
```

## Global search (toolbar single-search box)

The toolbar's single-search input (`<ActiveGrid toolbar={{search: {enabled: true}}}>`)
sends `globalFilter` to the fetch adapter. The resource view or custom server
implementation owns the searchable fields and query translation. Resource-backed
grids declare search capability through `serverCapabilities`; per-column
`column.server.global` markers support the non-capability integration's
diagnostics. A marker alone does not make a server field searchable.

Hydration prunes per-column sort/filter state, not the single global search
string according to individual column markers. The host must enforce its search
contract on the server.

Helpers from `@workspace/active-grid`:

| Helper | Declares |
| --- | --- |
| `globalField(path?, {operator?})` | Scalar-field participation in the global search. |
| `globalSomeByField({relation, path, operator?})` | Relation participation (`some`). |
| `globalEveryByField({relation, path, operator?})` | Relation participation (`every`). |
| `globalNoneByField({relation, path, operator?})` | Relation participation (`none`). |

Default operator is `"contains"` (case-insensitive). Override with
`"startsWith"` for prefix-match or `"equals"` for exact-match.

### Guide: relation columns in the global search — decide per grid

`globalSomeByField` (and `every`/`none`) makes it technically possible to let
the global search box search over relation columns. The resource view handles
it correctly server-side. But before you turn it on for a grid, it is a product
choice worth weighing:

**An example of the pattern:** a users grid with `globalSomeByField({relation: "userRoles", path: "role.name"})`
on the Roles column. A user who types "editor" in the search box sees every
row appear where one of the roles happens to be called "editor". That may be
exactly what the end user wants — or it may be confusing because the search
term matches an attribute that is not visible (the truncated roles badge in the
column may show only "Admin").

**Rule of thumb:** by default, limit the global search box to *scalar text
fields whose value is directly visible in the cell* (`email`, `displayName`,
`subject`, `name`, etc.). The user then sees at once why a row is in the
results. Relation search is possible, but:

- Do it deliberately per grid, not as a default pattern.
- Consider whether the column's filter icon (`column.server.filter` →
  filter dropdown) offers a better UX than the global search box for "show
  only X with role Y". A filter dropdown is explicit and predictable; global
  search is more of a free-text search action.
- Document per grid why the choice fell for or against relation search — a
  short comment in the column config gives future reviewers the reasoning.

No blocking technical decision point: `globalSomeByField` works. The choice is
purely UX. Fail-closed by default (no `global` mapping) is safer than making
everything globally searchable — add it explicitly per column.

## Server-side grouping (groupBy + aggregate)

The `groupBy` and `aggregate` sub-keys on `column.server` declare a column's
grouping and aggregation capability. When a grid runs with
`enableServerGrouping` and a non-empty `serverGroupBy`-state, the resource view
performs the grouped query server-side at each drill-down level and falls back
to a leaf query once the user has drilled into a specific group. The server resource declares and computes the aggregates. Client
`column.server` markers are hydration/capability declarations, not executable
server policy. See Server grouping for the current
request and response shapes, and
`../api/server-contract.md` § GroupBy helpers and
§ Aggregate helpers for the declaration helpers.

### Example

```typescript
{
  id: "salary",
  accessorKey: "salary",
  header: "Salary",
  server: {
    groupBy: groupByField(),                      // makes column draggable to group toolbar
    aggregate: aggregateScalar({ ops: ["sum"] }), // client declaration; server resource owns the aggregate
  },
}
```

### Fail-closed + reserved policy

- A `groupBy` instruction for a column without a `server.groupBy`
  mapping is **silently stripped** (dev-warn-once)
  — identical to sort/filter behavior.
- A `groupByRelation()` mapping is **reserved** — the runtime does not
  activate it (scalar group-by requires a scalar field name, which the
  `{relation, path}` shape cannot derive). Use `groupByField("<fkScalar>")`
  for 1:1 FK grouping; one-to-many grouping is defined server-side in the
  resource view.
- Aggregations are always declared explicitly per column: auto-aggregate is
  semantically dangerous (SUM on `versionNumber`, AVG on a `userId: Int`
  produce garbage).

Design history: ADR-017.

## Debouncing

Server mode applies debouncing to prevent excessive API calls:

| Input | Debounce | Configurable |
| --- | --- | --- |
| Global filter (toolbar search) | 300 ms | Via `GridConfig.behavior.debounceMs` |
| Column filters | 300 ms (server mode only) | Not yet configurable |
| Sorting | None (discrete action) | n/a |
| Pagination | None (discrete action) | n/a |

Column filters are debounced only in server mode. In client mode, filters
are applied immediately since there is no network cost.

## Error Handling

When `fetchFn` throws an error, the grid:

1. Catches the error and extracts the message.
2. Records the error in its internal data-orchestration state.
3. Displays an error overlay on top of the grid.
4. Provides a "Retry" button that clears the error and re-fetches.

```typescript
const fetchUsers: ServerFetchFunction<User> = async (params) => {
  const {signal, ...request} = params;
  const response = await fetch('/api/records', {
    method: 'POST',
    body: JSON.stringify(request),
    signal,
  });

  if (!response.ok) {
    throw new Error(`Failed to load users: ${response.statusText}`);
  }

  return response.json();
};
```

The error message is displayed in the overlay. Users can retry, which
triggers a fresh fetch with the same parameters.

### AbortController

Each fetch creates an `AbortController`. When parameters change before the
previous fetch completes, the old request is aborted to prevent stale data
from overwriting fresh results. The orchestrator enforces this via four
layered guards:

1. **`signal.aborted` check inside `fetchBlock`** — after `await fetchFn`,
   discard the result if the signal was aborted during the await.
2. **`signal.aborted` check after `retryWithBackoff`** — discard if
   aborted during the retry chain.
3. **`fetchDatasetKey === prevDatasetKeyRef.current`** — discard if the
   dataset key (which encodes sorting / filters / globalFilter / pageSize)
   changed during the await, even if the signal was not yet aborted (this
   covers the debounce window).
4. **`controllerRef.current?.abort()` on a new fetch start** — proactively
   abort the in-flight fetch before starting a new one.

Together these guarantee: an in-flight fetch whose parameters have become
stale **never** writes its result into state.

The `signal` is passed to `fetchFn` via `params.signal`, so consumers can
forward it to `fetch()` or use it to cancel database queries:

```typescript
const fetchUsers: ServerFetchFunction<User> = async (params) => {
  // Forward signal to fetch — browser cancels the HTTP request
  const response = await fetch('/api/records', {
    method: 'POST',
    body: JSON.stringify(params),
    signal: params.signal,
  });
  return response.json();
};
```

## Loading States

| State | Visual | When |
| --- | --- | --- |
| Initial loading | Nothing by default; explicit `loadingDisplay.firstLoad` can opt in | First fetch (no prior data) |
| Refetching | Subtle 2-pixel progress bar at the top of the grid | Subsequent fetches (pagination / sort / filter change) — stale data stays visible |
| Infinite-scroll loading more | Spinner + "Loading more..." text under the grid | Infinite-scroll mode fetching next block |
| Error | Centered error card with retry button | `fetchFn` throws and no retry remaining |

### Stale-while-revalidate (the refetch UX) — by design

When `pageIndex`, `sorting`, `columnFilters`, or `globalFilter` change
while existing data is already on screen, the grid does NOT clear the body
or dim it. The orchestrator keeps the old data fully visible and
interactive, shows a 2-pixel progress bar at the top, and replaces the
body atomically when the new fetch resolves. This is the classic
"stale-while-revalidate" pattern (similar to React Query's `isFetching`
behavior).

Implementation reference:

- The grid flags a refetch as soon as the dataset key changes on an
  already-initialised grid.
- While that flag is set the grid shows a 2-pixel progress bar and keeps the
  existing rows on screen unchanged, rather than swapping them for a
  placeholder. That bar is the refetch default: the rows on screen are stale,
  and a reader given no cue is looking at old data without knowing it.
- The *initial* load is a different state, and there the default is **nothing**.
  Only you know whether your data takes 50 ms or 3 seconds, so the library does
  not guess; pick an indicator with `loadingDisplay` if your fetch is slow
  enough to warrant one, and pair it with `delayMs` so a fast fetch never
  flashes. See Loading display.
- Independently of any of that, the pagination footer makes **no claim about
  the result count** until a fetch has answered — no row count, no page
  indicator. `totalCount === 0` is true both while loading and when the result
  really is empty, so counting rows alone cannot tell those apart. That
  suppression is correctness, not appearance, and is not configurable.

**Implications for consumers:**

- The 2-pixel progress bar is deliberately subtle. A user scrolled deep
  into a long table may not notice it; they will see new data appear
  mid-viewport without an obvious "loading just finished" cue.
- All four guards inside the orchestrator guarantee that stale fetch
  results **never** overwrite fresh ones. The user will always converge on
  correct data; the only question is the prominence of the loading
  indicator.
- Data orchestration is internal to the grid and has no public entry point.
  A consumer uses `<ActiveGrid />`; `loadingDisplay.refetch` controls the cue.

### Configuring the refetch cue

Use `loadingDisplay.refetch` and `delayMs` to choose a presentation appropriate
for the expected latency. The available indicators and defaults are documented
in Loading display.

### Test-discipline: always wait for both `isLoading=false` AND `isRefetching=false`

When writing tests that assert on grid data after a filter / sort / page
change, **always** wait for `isRefetching=false` too:

```ts
// ✗ Brittle — can satisfy with stale data still visible
await waitFor(() => {
  expect(result.current.isLoading).toBe(false);
  expect(result.current.data).toHaveLength(pageSize);
});

// ✓ Robust — waits for the new fetch to fully replace stale data
await waitFor(() => {
  expect(result.current.isLoading).toBe(false);
  expect(result.current.isRefetching).toBe(false);
  expect(result.current.data).toHaveLength(pageSize);
});
```

## Pagination

In server mode, the grid sends `pageIndex` and `fetchSize` to the server.
`fetchSize` is the engine's fetch-block size (rows requested per server
call) — the engine pages multiple UI rows out of a single block on the
client side, so the server returns exactly one block of rows per call. The
grid slices the requested visible page from the loaded block.

The UI total-page-count is derived from `totalCount / pagination.pageSize`
(the UI page size, separate from `fetchSize`).

### `pagination.mode="infinite"` is server-only

Infinite scroll loads successive blocks through `fetchFn`. A client-mode
grid already holds the whole dataset and has no `fetchFn`, so there is
nothing to load — the setting is **ignored** there and the grid falls back
to standard pagination, with a dev-warn once per `gridId`.

The clamp is deliberate rather than a silent downgrade. Without it a
client-mode grid with `mode: 'infinite'` hid both pagination bars (they
render behind `!isInfiniteScroll`) while the client paginated row-model
stayed active — so the grid showed one page, offered no way to reach the
rest, and rendered an "All N rows loaded" footer reporting the *full* row
count. See `DONE.md` § 7.5.0.

## Complete Example with a resource view

Server-mode data access goes through a resource view defined in
`@workspace/active-resources`. The view owns the model, permission, `select`,
and `where` fragments server-side; the client wires the grid to it with
`createResourceServerFetchFn`. Sort / filter / global-search / paging state
flows into the view automatically — you do not hand-build a Prisma query in
the browser.

```tsx
// user-grid.tsx
'use client';

import {
  ActiveGrid,
  createResourceServerFetchFn,
  type GridPresentation,
} from '@workspace/active-grid';
import {getUsersResourceAction} from './actions'; // active-resources data-action

type User = {id: string; firstName: string; lastName: string; email: string};

const fetchUsers = createResourceServerFetchFn<User>({
  serverAction: getUsersResourceAction,
  viewKey: 'users.list',
});

export function UserGrid({locale}: {locale: GridPresentation}) {
  return (
    <ActiveGrid
      gridId="users"
      locale={locale}
      mode="server"
      fetchFn={fetchUsers}
      columns={columns}
      height={600}
      enableUrlSync
      pagination={{enabled: true}}
    />
  );
}
```

The `users.list` view declaration lives in `@workspace/active-resources` and
is where the model, permission, filterable/sortable fields, and any
authorization scope are defined. See
`../api/resource-mode.md` for the full adapter and
capabilities surface.
