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.
| Kind | What runs | What it proves |
|---|---|---|
| Client | all rows in the browser; the grid sorts, filters and pages | the grid's behaviour on data it holds |
| Simulation | a fetch with a delay over rows that are in the browser | the interaction of server mode, not a server |
| Server | a real route of this site that answers each request | the host contract of server mode, from end to end |
Choosing a mode#
| Question | Client mode | Server mode |
|---|---|---|
| How many rows? | hundreds to a few thousand | any number: one block at a time |
| May the user see every row? | yes, they are all sent | only the rows your server returns |
| Who sorts, filters and searches? | the grid, in the browser | your server, from the grid's request |
| What does a filter change cost? | nothing: the rows are there | a request; the grid debounces and cancels |
| Totals and options of the select filters | computed by the grid | your server sends total 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 fetch, 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.
// apps/docs/examples/grid/client-orders.tsx'use client';
import '@workspace/active-grid/styles.css';
import {useMemo} from 'react';import {generateOrders, type Order} from '@/lib/examples/orders';
import { composePresentationContext, toIanaTimeZone,} from '@workspace/active-format';import { ActiveGrid, createColumnHelper, englishGridTranslator, type GridColumnDef,} from '@workspace/active-grid';
// The presentation: language, notation and the time zone the date filter// needs for its relative presets and calendar.const presentation = composePresentationContext( 'en-GB', null, toIanaTimeZone('Europe/Amsterdam'),);
const STATUS = [ {label: 'Pending', value: 'pending'}, {label: 'Processing', value: 'processing'}, {label: 'Shipped', value: 'shipped'}, {label: 'Delivered', value: 'delivered'}, {label: 'Cancelled', value: 'cancelled'},];
const ch = createColumnHelper<Order>();const columns: GridColumnDef<Order>[] = [ ch.accessor('id', {header: 'Order', meta: {filterType: 'text'}}), ch.accessor('customer', {header: 'Customer', meta: {filterType: 'text'}}), ch.accessor('category', { header: 'Category', meta: {filterType: 'select'}, }), ch.accessor('quantity', {header: 'Qty', type: 'number'}), ch.accessor('total', { header: 'Total', type: 'currency', meta: {currencyCode: 'EUR', filterType: 'number'}, }), ch.accessor('orderDate', {header: 'Ordered', type: 'date'}), ch.accessor('status', { header: 'Status', type: 'badge', meta: { filterType: 'select', filterOptions: STATUS, statusTones: { pending: 'warning', processing: 'info', shipped: 'info', delivered: 'success', cancelled: 'danger', }, }, }),];
/** Client mode: all 500 rows are in the browser; the grid sorts, filters and pages them there. */export default function ClientOrders() { const data = useMemo(() => generateOrders(500), []); return ( <ActiveGrid<Order> gridId="docs-client-orders" mode="client" data={data} columns={columns} height={420} locale={presentation} t={englishGridTranslator} enableRowSelection pagination={{enabled: true, pageSizeOptions: [10, 25, 50]}} toolbar={{search: {enabled: true}, showColumnChooser: true}} /> );}Server mode against a real route#
mode="server" with a fetch. The grid asks for one block of rows at a time and sends what
to apply: the block (page and fetch), 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.
Requests (POST /api/examples/grid/orders)
// apps/docs/examples/grid/server-orders.tsx'use client';
import '@workspace/active-grid/styles.css';
import {useCallback, useState} from 'react';import type {Order} from '@/lib/examples/orders';
import { composePresentationContext, toIanaTimeZone,} from '@workspace/active-format';import { ActiveGrid, createColumnHelper, englishGridTranslator, globalField, scalarField, type GridColumnDef, type PagedResponse, type ServerFetchFunction,} from '@workspace/active-grid';
const presentation = composePresentationContext( 'en-GB', null, toIanaTimeZone('Europe/Amsterdam'),);
const ch = createColumnHelper<Order>();// In server mode the column contract (`server`) declares what the server can// sort and filter; the grid offers exactly that, and nothing the server would// refuse.const columns: GridColumnDef<Order>[] = [ ch.accessor('id', { header: 'Order', meta: {filterType: 'text'}, server: {...scalarField(), global: globalField()}, }), ch.accessor('customer', { header: 'Customer', meta: {filterType: 'text'}, server: {...scalarField(), global: globalField()}, }), ch.accessor('category', { header: 'Category', meta: {filterType: 'select'}, server: {...scalarField(), global: globalField()}, }), ch.accessor('total', { header: 'Total', type: 'currency', meta: {currencyCode: 'EUR', filterType: 'number'}, server: scalarField(), }), ch.accessor('orderDate', { header: 'Ordered', type: 'date', meta: {filterType: 'date'}, server: scalarField(), }), ch.accessor('status', { header: 'Status', meta: {filterType: 'select'}, server: scalarField(), }),];
interface Exchange { n: number; request: string; answer: string;}
/** * Server mode against a real route of this site. The browser sends the * grid's request (page, block size, sorting, filters, search); the server * filters, sorts and pages 5,000 orders and answers with one block. */export default function ServerOrders() { const [log, setLog] = useState<Exchange[]>([]); const fetchOrders = useCallback<ServerFetchFunction<Order>>( async ({signal, ...params}) => { const body = JSON.stringify(params); const response = await fetch('/api/examples/grid/orders', { method: 'POST', headers: {'Content-Type': 'application/json'}, body, signal, }); const text = await response.text(); if (!response.ok) throw new Error(`The server refused: ${text}`); const page = JSON.parse(text) as PagedResponse<Order>; setLog((list) => [ { n: (list[0]?.n ?? 0) + 1, request: body, answer: `${page.data.length} rows of ${page.totalCount}, ${(text.length / 1024).toFixed(1)} kB`, }, ...list, ].slice(0, 5), ); return page; }, [], ); return ( <div className="flex flex-col gap-4"> <ActiveGrid<Order> gridId="docs-server-orders" mode="server" fetchFn={fetchOrders} columns={columns} height={380} locale={presentation} t={englishGridTranslator} pagination={{enabled: true, pageSizeOptions: [10, 25, 50]}} toolbar={{search: {enabled: true}}} serverOptions={{blockSize: 50, retryCount: 0}} /> <section aria-label="Requests to the server" data-request-log> <h3 className="mb-2 text-sm font-semibold"> Requests (POST /api/examples/grid/orders) </h3> <ol className="flex flex-col gap-2"> {log.map((exchange) => ( <li key={exchange.n} className="bg-muted rounded-md p-2 font-mono text-xs break-all" data-request={exchange.n} > <div> #{exchange.n} โ {exchange.request} </div> <div className="text-muted-foreground">โ {exchange.answer}</div> </li> ))} </ol> </section> </div> );}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 search | a 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 receives | translates filters and sorting into a query of its data store |
| debounces typing in a filter or the search | decides who may read which rows and columns (authorization, tenancy) |
| shows loading, empty and error states | returns 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.
// 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'}, });}// 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):
// 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, total, facets), cancellation of
a superseded request through the Abort, 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-text).MM- dd
Simulation: the states of server mode without a server#
A fetch 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.
// 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> );}The request contract in short#
The grid sends (Server) | Your server answers (Paged) |
|---|---|
page, fetch: one block, not the UI page | data: the rows of that block |
sorting: [{id, desc}] | total: the number of rows after filtering |
filters: [{id, value}] in the operator forms of Filter | facets (optional): options with counts per column |
global: the search text | aggregations (optional): footer values |
signal: aborted when a newer request supersedes this one | groups, for server grouping |
The function itself is a
Server; 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.