Active Foundation

Search documentation

Search all pages, headings and text, in the current package or topic or in all documentation.

Server mode

How server mode works: the fetch function, blocks, the request parameters, mutations and the patterns to avoid.

@workspace/active-grid 11.0.0Status unknowncontent differs from the recorded release 11.0.0baseline ce081dc3

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.

Server mode against an imitated serverSimulationA server imitated in your browser: shows the interaction, not a working server integration.

Skip past grid rows
Order
Product
Qty
Total
End of grid rows

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#

ParameterTypeDescription
pageIndexnumberZero-based fetch-block index. First block is 0.
fetchSizenumberNumber of rows the engine wants in this fetch (a fetch-block size, not the UI page size). Pass through to your Prisma take.
sortingSortingStateArray of {id: string, desc: boolean}. Empty array means no sorting.
filtersColumnFiltersStateArray of {id: string, value: FilterValue}. Each value is a discriminated union.
globalFilterstring | undefinedGlobal search term from the toolbar search input.
signalAbortSignalSignal 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#

FieldTypeRequiredDescription
dataTData[]YesRow data for the current page.
totalCountnumberYesTotal rows across all pages (for pagination calculation).
facetsRecord<string, FacetOption[]>NoServer-calculated filter options per column.
aggregationsRecord<string, number | string>NoServer-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.

WarningTriggerFix
maxFetchSize below engine block sizeThe 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-opgridRef.current?.addRow(...) or .removeRow(...) called while the grid is in mode="client".Mutate your own data state.
<ActiveGrid key={...}> remountThe 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 callbackeditMode="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 datasourceServer 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 blockSizeserverOptions.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,// }));

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:

HelperDeclares
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:

InputDebounceConfigurable
Global filter (toolbar search)300 msVia GridConfig.behavior.debounceMs
Column filters300 ms (server mode only)Not yet configurable
SortingNone (discrete action)n/a
PaginationNone (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#

StateVisualWhen
Initial loadingNothing by default; explicit loadingDisplay.firstLoad can opt inFirst fetch (no prior data)
RefetchingSubtle 2-pixel progress bar at the top of the gridSubsequent fetches (pagination / sort / filter change) โ€” stale data stays visible
Infinite-scroll loading moreSpinner + "Loading more..." text under the gridInfinite-scroll mode fetching next block
ErrorCentered error card with retry buttonfetchFn 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:

TypeScript
// โœ— Brittle โ€” can satisfy with stale data still visibleawait waitFor(() => {  expect(result.current.isLoading).toBe(false);  expect(result.current.data).toHaveLength(pageSize);});
// โœ“ Robust โ€” waits for the new fetch to fully replace stale dataawait 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.

This page as Markdown