Active Foundation

Search documentation

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

Client, server and simulation

Where the rows of a grid live, who sorts, filters and pages them, and what each of the three kinds of example on this site proves.

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

A grid has its rows in one of two places. In client mode the browser holds every row and the grid sorts, filters and pages them itself. In server mode the rows stay on a server: the grid asks for one block at a time and says what to apply, and your server answers with that block.

The examples on this site come in three kinds, and each says what it proves.

KindWhat runsWhat it proves
Clientall rows in the browser; the grid sorts, filters and pagesthe grid's behaviour on data it holds
Simulationa fetchFn with a delay over rows that are in the browserthe interaction of server mode, not a server
Servera real route of this site that answers each requestthe host contract of server mode, from end to end

Choosing a mode#

QuestionClient modeServer mode
How many rows?hundreds to a few thousandany number: one block at a time
May the user see every row?yes, they are all sentonly the rows your server returns
Who sorts, filters and searches?the grid, in the browseryour server, from the grid's request
What does a filter change cost?nothing: the rows are therea request; the grid debounces and cancels
Totals and options of the select filterscomputed by the gridyour server sends totalCount and facets

Start in client mode when the data is small and the user may see all of it. Move to server mode as soon as either is no longer true: the switch is the mode prop, a fetchFn, and the route behind it. Column definitions, styling, selection and export stay the same.

Client mode: the starting point#

mode="client" with data: the grid holds every row. Sorting, the column filters, the global search and paging all work on the rows it has. This is the smallest working grid on this site.

Orders in client modeClient

Skip past grid rows
Order
Customer
Category
Qty
Total
Ordered
Status
End of grid rows

Server mode against a real route#

mode="server" with a fetchFn. The grid asks for one block of rows at a time and sends what to apply: the block (pageIndex and fetchSize), the sorting, the column filters and the global search. The server below holds 5,000 seeded orders that never leave it; it validates the request, filters, searches, sorts and slices on the server, and answers with one block, the total count and the facets for the select filters.

Server mode against a real routeServerCalls a real route of this site, which does the work on the server.

Skip past grid rows
Order
Customer
Category
Total
Ordered
Status
End of grid rows

Requests (POST /api/examples/grid/orders)

    Under the grid, the request window shows each request your browser sent and the size of the answer. Try the combinations a user makes: filter on a status, sort by amount, search for a customer, and page on. Each change is one request with the whole query, and one block comes back, never the dataset.

    What the package does, and what your host does#

    The grid (active-grid)Your host
    sends one request per change, with the block, sorting, filters and searcha route that validates every field of the request before it uses it
    cancels a request that a newer one supersedes (signal)passes the signal on to its own data access, so abandoned work stops
    keeps the block, the total and the facets it receivestranslates filters and sorting into a query of its data store
    debounces typing in a filter or the searchdecides who may read which rows and columns (authorization, tenancy)
    shows loading, empty and error statesreturns errors the grid can show, and never leaks the reason to a stranger

    The server side#

    The route this example calls, and the module that holds the data and validates the request. Both run only on the server.

    app/api/examples/grid/orders/route.ts
    // apps/docs/app/api/examples/grid/orders/route.ts
    import {answerOrderQuery} from '@/lib/examples/orders-server';
    /** * The server of the grid's server examples: one POST per block. The body is * the grid's fetch request (page index, block size, sorting, filters, global * search); the server filters, sorts and slices its seeded dataset and * answers with one block and the total count. Nothing is cached. */export const runtime = 'nodejs';export const dynamic = 'force-dynamic';
    export async function POST(request: Request) {  const body: unknown = await request.json().catch(() => null);  const answer = answerOrderQuery(body);  if (!answer.ok) {    return Response.json({error: answer.error}, {status: 400});  }  return Response.json(answer.page, {    headers: {'Cache-Control': 'no-store'},  });}
    lib/examples/orders-server.ts
    // apps/docs/lib/examples/orders-server.tsimport 'server-only';
    import {z} from 'zod';
    import {generateOrders, queryOrders} from './orders';
    /** * The server side of the grid's server examples: 5,000 seeded orders that * exist only on this server, and the validation of a request before the * query runs. A browser never receives this dataset; it receives one block * of rows per request. */const ORDERS = generateOrders(5000);
    const filterValue = z.union([  z.array(z.string()).max(50),  z.object({    operator: z.string().max(32),    value: z.union([z.string().max(200), z.number(), z.boolean()]).optional(),    valueTo: z.union([z.string().max(200), z.number()]).optional(),  }),]);
    const orderQuery = z.object({  pageIndex: z.number().int().min(0).max(10_000),  fetchSize: z.number().int().min(1).max(500),  sorting: z.array(z.object({id: z.string(), desc: z.boolean()})).max(5),  filters: z.array(z.object({id: z.string(), value: filterValue})).max(20),  globalFilter: z.string().max(200).optional(),});
    /** Validates `body` and runs the query; a request outside the contract is refused. */export function answerOrderQuery(body: unknown) {  const parsed = orderQuery.safeParse(body);  if (!parsed.success) return {ok: false as const, error: 'Invalid request'};  try {    return {ok: true as const, page: queryOrders(ORDERS, parsed.data)};  } catch (err) {    return {      ok: false as const,      error: err instanceof RangeError ? err.message : 'Invalid request',    };  }}

    The query itself (filtering by the grid's operator forms, search, sorting and the block):

    lib/examples/orders.ts
    // apps/docs/lib/examples/orders.ts
    /** * A deterministic, seeded dataset of orders for the grid examples, and the * query the server examples apply to it: filtering, global search, sorting * and one block of rows, in the shapes of the grid's `ServerFetchParams` * and `PagedResponse`. * * The generator and its filter semantics follow the seeded demo dataset of * the grid lab the examples came from (orders over six categories, a * mulberry32 generator with seed 123). Pure: no I/O, no clock, no * randomness beyond the seed, so every reader and every test sees the same * rows. The server examples keep the dataset on the server * (`orders-server.ts`); only the client and simulation examples build it in * the browser. */
    export interface Order {  id: string;  customer: string;  email: string;  product: string;  category: string;  quantity: number;  unitPrice: number;  total: number;  /** A calendar day, `yyyy-MM-dd`. */  orderDate: string;  status: OrderStatus;  priority: 'low' | 'medium' | 'high' | 'urgent';}
    export type OrderStatus =  'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
    export const ORDER_STATUSES: readonly OrderStatus[] = [  'pending',  'processing',  'shipped',  'delivered',  'cancelled',];
    export const CATEGORIES = [  'Electronics',  'Clothing',  'Home',  'Books',  'Sports',  'Food',] as const;
    const PRODUCTS: Record<(typeof CATEGORIES)[number], string[]> = {  Electronics: ['Wireless Headphones', 'USB-C Hub', 'Laptop Stand', 'Webcam'],  Clothing: ['Cotton T-Shirt', 'Denim Jeans', 'Winter Jacket', 'Wool Sweater'],  Home: ['Desk Lamp', 'Wall Clock', 'Kitchen Scale', 'Plant Pot'],  Books: [    'TypeScript Handbook',    'Design Patterns',    'Clean Code',    'UX Research',  ],  Sports: ['Yoga Mat', 'Water Bottle', 'Tennis Racket', 'Foam Roller'],  Food: ['Organic Coffee', 'Green Tea', 'Dark Chocolate', 'Olive Oil'],};
    const FIRST_NAMES = ['Emma', 'Liam', 'Olivia', 'Noah', 'Ava', 'Ethan', 'Mia'];const LAST_NAMES = ['Smith', 'Garcia', 'Jansen', 'Kowalski', 'Haddad', 'Sato'];
    /** mulberry32: a small, seeded generator; the same seed gives the same rows. */function mulberry32(seed: number): () => number {  let state = seed;  return () => {    state |= 0;    state = (state + 0x6d2b79f5) | 0;    let t = Math.imul(state ^ (state >>> 15), 1 | state);    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;  };}
    function pick<T>(list: readonly T[], rand: () => number): T {  return list[Math.floor(rand() * list.length)]!;}
    /** `count` orders, always the same for the same count. */export function generateOrders(count: number): Order[] {  const rand = mulberry32(123);  const orders: Order[] = [];  for (let i = 0; i < count; i += 1) {    const first = pick(FIRST_NAMES, rand);    const last = pick(LAST_NAMES, rand);    const category = pick(CATEGORIES, rand);    const product = pick(PRODUCTS[category], rand);    const quantity = 1 + Math.floor(rand() * 50);    const unitPrice = Math.round((5 + rand() * 994) * 100) / 100;    const year = 2024 + Math.floor(rand() * 3);    const month = 1 + Math.floor(rand() * 12);    const day = 1 + Math.floor(rand() * 28);    const s = rand();    const p = rand();    orders.push({      id: `ORD-${String(i + 1).padStart(5, '0')}`,      customer: `${first} ${last}`,      email: `${first.toLowerCase()}.${last.toLowerCase()}@example.com`,      product,      category,      quantity,      unitPrice,      total: Math.round(quantity * unitPrice * 100) / 100,      orderDate: `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,      status:        s < 0.15          ? 'pending'          : s < 0.35            ? 'processing'            : s < 0.6              ? 'shipped'              : s < 0.9                ? 'delivered'                : 'cancelled',      priority:        p < 0.35 ? 'low' : p < 0.7 ? 'medium' : p < 0.9 ? 'high' : 'urgent',    });  }  return orders;}
    /** One column filter as the grid sends it: an operator form or a selection. */export type OrderFilterValue =  | string[]  | {      operator: string;      value?: string | number | boolean;      valueTo?: string | number;    };
    /** What a fetch asks for: the serialisable part of the grid's `ServerFetchParams`. */export interface OrderQuery {  pageIndex: number;  fetchSize: number;  sorting: {id: string; desc: boolean}[];  /** As the grid sends them; a value outside `OrderFilterValue` filters nothing. */  filters: {id: string; value: unknown}[];  globalFilter?: string;}
    /** One block of rows, in the shape of the grid's `PagedResponse`. */export interface OrderPage {  data: Order[];  totalCount: number;  facets: Record<string, {label: string; value: string; count: number}[]>;}
    const SEARCHED: (keyof Order)[] = [  'id',  'customer',  'email',  'product',  'category',];const COLUMNS = new Set<string>([  'id',  'customer',  'email',  'product',  'category',  'quantity',  'unitPrice',  'total',  'orderDate',  'status',  'priority',]);
    function isOperatorForm(  value: unknown,): value is Exclude<OrderFilterValue, string[]> {  return (    typeof value === 'object' &&    value !== null &&    typeof (value as {operator?: unknown}).operator === 'string'  );}
    function matches(cell: unknown, filter: unknown): boolean {  if (Array.isArray(filter)) {    return filter.length === 0 || filter.map(String).includes(String(cell));  }  if (!isOperatorForm(filter)) return true;  const {operator, value, valueTo} = filter;  if (operator === 'isEmpty') return cell === null || cell === '';  if (operator === 'isNotEmpty') return cell !== null && cell !== '';  if (value === undefined || value === '') return true;  if (typeof cell === 'number') {    const n = Number(value);    switch (operator) {      case 'equals':        return cell === n;      case 'notEquals':        return cell !== n;      case 'greaterThan':        return cell > n;      case 'greaterThanOrEqual':        return cell >= n;      case 'lessThan':        return cell < n;      case 'lessThanOrEqual':        return cell <= n;      case 'between':      case 'inRange':        return cell >= n && cell <= Number(valueTo);      default:        return true;    }  }  const text = String(cell ?? '');  const wanted = String(value);  // A calendar day compares as text: `yyyy-MM-dd` sorts as it reads.  switch (operator) {    case 'onDate':      return text === wanted.slice(0, 10);    case 'before':      return text < wanted.slice(0, 10);    case 'after':      return text > wanted.slice(0, 10);    case 'between':    case 'inRange':      return (        text >= wanted.slice(0, 10) && text <= String(valueTo).slice(0, 10)      );  }  const a = text.toLowerCase();  const b = wanted.toLowerCase();  switch (operator) {    case 'contains':      return a.includes(b);    case 'notContains':      return !a.includes(b);    case 'startsWith':      return a.startsWith(b);    case 'endsWith':      return a.endsWith(b);    case 'equals':      return a === b;    case 'notEquals':      return a !== b;    default:      return true;  }}
    function facets(rows: Order[], field: 'category' | 'status' | 'priority') {  const counts = new Map<string, number>();  for (const row of rows)    counts.set(row[field], (counts.get(row[field]) ?? 0) + 1);  return [...counts]    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))    .map(([value, count]) => ({label: value, value, count}));}
    /** * Filters, searches, sorts and returns one block of `orders`. Unknown * columns in a filter or sort are refused, as a host refuses anything its * contract does not name. */export function queryOrders(  orders: readonly Order[],  query: OrderQuery,): OrderPage {  for (const item of [...query.filters, ...query.sorting]) {    if (!COLUMNS.has(item.id))      throw new RangeError(`Unknown column ${item.id}`);  }  let rows = [...orders];  const search = query.globalFilter?.trim().toLowerCase();  if (search) {    rows = rows.filter((row) =>      SEARCHED.some((field) =>        String(row[field]).toLowerCase().includes(search),      ),    );  }  for (const filter of query.filters) {    rows = rows.filter((row) =>      matches(row[filter.id as keyof Order], filter.value),    );  }  if (query.sorting.length > 0) {    rows.sort((a, b) => {      for (const sort of query.sorting) {        const x = a[sort.id as keyof Order];        const y = b[sort.id as keyof Order];        const order =          typeof x === 'number' && typeof y === 'number'            ? x - y            : String(x) < String(y)              ? -1              : String(x) > String(y)                ? 1                : 0;        if (order !== 0) return sort.desc ? -order : order;      }      return 0;    });  }  const start = query.pageIndex * query.fetchSize;  return {    data: rows.slice(start, start + query.fetchSize),    totalCount: rows.length,    facets: {      category: facets(rows, 'category'),      status: facets(rows, 'status'),      priority: facets(rows, 'priority'),    },  };}

    What this example proves, and what it does not#

    It proves that the grid's server mode works against a real HTTP route of a host: the request shape the grid sends, the response shape it needs (data, totalCount, facets), cancellation of a superseded request through the AbortSignal, and that only one block reaches the browser.

    It does not prove:

    • your database: the translation of filters and sorting into a query of your data store, its indexes and its query plans. The seeded dataset is filtered in memory;

    • authorization and tenancy: this route answers anyone; your route decides who may see which rows and columns;

    • production load: real latency, concurrency, large result sets and caching;

    • your serialization: decimals and dates from a database (here every value is already a plain JSON number or a yyyy-MM-dd text).

    Simulation: the states of server mode without a server#

    A fetchFn that waits and then answers from rows already in the browser. It is not a server: nothing leaves the page. Use it to see what the grid does while it waits, when an answer is empty, and when a request fails: the switch below the grid makes the next request fail.

    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

    The request contract in short#

    The grid sends (ServerFetchParams)Your server answers (PagedResponse)
    pageIndex, fetchSize: one block, not the UI pagedata: the rows of that block
    sorting: [{id, desc}]totalCount: the number of rows after filtering
    filters: [{id, value}] in the operator forms of FilterValuefacets (optional): options with counts per column
    globalFilter: the search textaggregations (optional): footer values
    signal: aborted when a newer request supersedes this onegroups, for server grouping

    The function itself is a ServerFetchFunction; what each column may ask of the server (sort, filter, search, group) is its column query contract.

    Next#

    • Server mode: blocks, the request parameters, mutations and the patterns to avoid.

    • Server grouping: groups and their totals from the server.

    • Filtering: the filter types and the operator forms a server receives.

    • Selection: select-all in server mode without loading every row.

    • Export: which rows a file holds, and the formats.

    This page as Markdown