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
This guide is the package's docs/, 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.
// 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 ../.
fetchFn Interface#
Server mode requires a fetch prop โ an async function that receives
grid state and returns paginated data:
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 |
|---|---|---|
page | number | Zero-based fetch-block index. First block is 0. |
fetch | 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 | Sorting | Array of {id: string, desc: boolean}. Empty array means no sorting. |
filters | Column | Array of {id: string, value: Filter. Each value is a discriminated union. |
global | string | undefined | Global search term from the toolbar search input. |
signal | Abort | 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 Filter objects โ discriminated unions
based on the operator:
// 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. |
total | number | Yes | Total rows across all pages (for pagination calculation). |
facets | Record<string, Facet | 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:
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:
return { data: orders, totalCount: 5000, aggregations: { totalAmount: 1250000, quantity: 45000, },};The column's meta. is prepended when rendering.
Getting rows: the resource pipeline#
active- no longer builds or resolves server-side queries, and there is
no / subpath. Every server-mode grid obtains its rows from a resource
view defined in @workspace/, wired to the grid with
create. 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.
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 ../ for the full
Create surface and the server prop.
A source that cannot be a resource view is a narrow, recorded exception with
server-side conditions; see
../. It still
adapts its result to Server. The transport fragments above
illustrate that function shape, not an additional default data-access
architecture.
Mutating server-mode grids (use Grid Mutation)#
The recommended consumer API for "run async mutation, then patch the grid"
is the use hook. It runs an async action, applies a named
grid-strategy on success, and tracks is โ all without remounting
the grid.
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:
on. The five
strategies, is and the typed result are listed in
../ ยง useGridMutation.
Never use <Active to force a refresh โ
that remounts the whole grid and discards all session state.
Imperative primitive (grid Ref / use Grid Imperative)#
The package still exports Grid and the typed
use wrapper. These are now considered the
package-internal primitive that use is built on top of.
Production consumer-code must use use 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/itself.active- grid 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
gridoutside a mutation flow.Ref. current?. reset Selection()
All other consumer-side usage should go through use.
Client-mode mutations#
In mode="client" the grid reads the data prop directly each render and
grid/add are no-ops (with a dev-time warning). Mutate
your own data state instead:
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)#
max< 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.Fetch Size fetchis the engine's fetch-block size, not the UI page size โSize Serveris the row-count the engine wants for this server call. The UI page size is independent and lives onFetch Params. fetch Size pagination.; the engine collapses multiple UI pages into a single fetch block for cache efficiency. Passpage Size Options fetchstraight through to your PrismaSize take(or equivalent).<Activeremount anti-pattern โ incrementing aGrid key={...}> keyprop after each mutation destroys grid session state (filters, sort, column widths, scroll position, selection). Useuseโ or, for ad-hoc cases,Grid Mutation(grid Ref) grid/Ref. current?. remove Row(id) .โ instead. A dev-time detector emits a warn-once log when the samerefetch() gridmounts twice outside the React-Strict-Mode noise window.Id Silent client-mode
removeโ inRow mode="client"the imperative API is a no-op. Manage data viasetand pass the new array as theData(...) dataprop. The grid emits a dev warning ifremove/Row addis called against a client-mode grid.Row router.after grid mutations โrefresh() router.invalidates the surrounding Next.js Router Cache but does not retrigger the grid'srefresh() fetch. For server-mode grids, useFn grid, orRef. refetch() gridto 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 inRef. remove Row(id) ../). ReserveROADMAP. md router.for refreshing server-rendered shell elements outside the grid.refresh() A changed scope with an unchanged
fetchidentity โ the grid re-fetches on ITS own state (page, sort, filter, search, page size). It cannot see anything yourFn fetchcloses over: a resourceFn scope, a preset, a tenant id, an external filter the page owns in the URL. The preferred signal isserver, with aQuery Key fetchmemoised on the values the scope is built from. Memoised onFn []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. Seeapi/ยง The external query.server- mode. md A route query key colliding with
enableโ the grid claims exactlyUrl Sync page,page,Size sort,filtersandsearch, and merges rather than replaces (ADR-023). A page that owns a key of the same NAME shares it:pageandsortare the realistic collisions. A route with its own pager, or its own sort grammar such assort=name-, must not enableasc enableon that grid โ the grid would readUrl Sync page=2as its own page index and re-serialisesortinid:ascform. Either the route owns the URL or the grid does.Reading a stalled grid as a slow one โ a server-mode grid that never fetches renders exactly like one that is still fetching:
isstartsLoading 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. guard so the
detector code tree-shakes out of the consumer bundle. Each one is a warn
diagnostic from source Active, delivered to the
<Grid sink (the console without one), once per ground.
| Warning | Trigger | Fix |
|---|---|---|
max below engine block size | The action's max is below the engine's default block size (200). | Raise max or lower the engine's block via server. |
Client-mode add / remove no-op | grid or . called while the grid is in mode="client". | Mutate your own data state. |
<Active remount | The same grid mounts twice outside the React-Strict-Mode noise window (i.e. the consumer changed key={...}). | Switch to use or call grid / .. |
Client-mode pagination. | pagination={{mode: 'infinite'}} on a mode="client" grid. The setting is ignored and standard pagination is used. | Remove pagination. or switch the grid to mode="server". |
edit without its commit callback | edit without on, or "cell"/"row" with only on. Editing is disabled entirely. | Supply the callback that matches the mode โ see editing.. |
| 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 fetch, state that never hydrated, or a fetch identity that never settled. |
Inert block | server is smaller than the UI page size, so pages floors to 1 and every page change is a server call. Once per grid + blockSize + pageSize. | Raise block to a multiple of the largest page size, or drop it and take the default (200). |
Note the difference between the two block diagnostics. An inert
block size warns; an invalid one (0, negative, Na, Infinity,
non-integer, non-number) throws a GridConfigurationError with code
GRID_SERVER_OPTIONS_INVALID โ see
api/ ยง Valid ranges.
The remount detector fires once per grid 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.
and grid.; a React component observes it with
use. 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.
// 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 (<Active)
sends global 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 server; per-column
column. 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/:
| Helper | Declares |
|---|---|
global | Scalar-field participation in the global search. |
global | Relation participation (some). |
global | Relation participation (every). |
global | Relation participation (none). |
Default operator is "contains" (case-insensitive). Override with
"starts for prefix-match or "equals" for exact-match.
Guide: relation columns in the global search โ decide per grid#
global (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 global
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, display,
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.โ 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.server. filter 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: global 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 group and aggregate sub-keys on column. declare a column's
grouping and aggregation capability. When a grid runs with
enable and a non-empty server-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. markers are hydration/capability declarations, not executable
server policy. See Server grouping for the current
request and response shapes, and
../ ยง GroupBy helpers and
ยง Aggregate helpers for the declaration helpers.
Example#
{ 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
groupinstruction for a column without aBy server.mapping is silently stripped (dev-warn-once) โ identical to sort/filter behavior.group By A
groupmapping is reserved โ the runtime does not activate it (scalar group-by requires a scalar field name, which theBy Relation() {relation, path}shape cannot derive). Usegroupfor 1:1 FK grouping; one-to-many grouping is defined server-side in the resource view.By Field("<fk Scalar>") Aggregations are always declared explicitly per column: auto-aggregate is semantically dangerous (SUM on
version, AVG on aNumber userproduce garbage).Id: Int
Design history: ADR-017.
Debouncing#
Server mode applies debouncing to prevent excessive API calls:
| Input | Debounce | Configurable |
|---|---|---|
| Global filter (toolbar search) | 300 ms | Via Grid |
| 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 fetch throws an error, the grid:
Catches the error and extracts the message.
Records the error in its internal data-orchestration state.
Displays an error overlay on top of the grid.
Provides a "Retry" button that clears the error and re-fetches.
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 Abort. 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:
signal.check insideaborted fetchโ afterBlock await fetch, discard the result if the signal was aborted during the await.Fn signal.check afteraborted retryโ discard if aborted during the retry chain.With Backoff fetchโ 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).Dataset Key === prev Dataset Key Ref. current controlleron a new fetch start โ proactively abort the in-flight fetch before starting a new one.Ref. current?. abort()
Together these guarantee: an in-flight fetch whose parameters have become stale never writes its result into state.
The signal is passed to fetch via params., so consumers can
forward it to fetch() or use it to cancel database queries:
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 loading 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 | fetch throws and no retry remaining |
Stale-while-revalidate (the refetch UX) โ by design#
When page, sorting, column, or global 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 is
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
loadingif your fetch is slow enough to warrant one, and pair it withDisplay delayso a fast fetch never flashes. See Loading display.Ms 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.
totalis 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.Count === 0
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
<Active;Grid / > loadingcontrols the cue.Display. refetch
Configuring the refetch cue#
Use loading and delay 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 is Loading=false AND is Refetching=false#
When writing tests that assert on grid data after a filter / sort / page
change, always wait for is too:
// โ 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 page and fetch to the server.
fetch 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 total
(the UI page size, separate from fetch).
pagination. mode="infinite" is server-only#
Infinite scroll loads successive blocks through fetch. A client-mode
grid already holds the whole dataset and has no fetch, 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 grid.
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 !is) 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. ยง 7.5.0.
Complete Example with a resource view#
Server-mode data access goes through a resource view defined in
@workspace/. The view owns the model, permission, select,
and where fragments server-side; the client wires the grid to it with
create. Sort / filter / global-search / paging state
flows into the view automatically โ you do not hand-build a Prisma query in
the browser.
// 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. view declaration lives in @workspace/ and
is where the model, permission, filterable/sortable fields, and any
authorization scope are defined. See
../ for the full adapter and
capabilities surface.