Skip to content

Server-driven lists

When a list page is backed by a server — millions of rows, real filters, real pagination — you want Strata to push sort, filter, and paging to the API instead of loading everything into the browser. That’s what the DataSource interface is for.

This guide walks through the end-to-end pattern for wiring Strata into a typical enterprise list endpoint: write a small adapter that translates your API contract, declare server capabilities, hand the adapter to <DataGrid>, and round-trip view state through the URL. The same pattern covers REST + skip/take, OData, GraphQL, or anything else.

The contract

DataSource<TRow> has one method you must implement and a few optional ones:

interface DataSource<TRow> {
load(query?: DataQuery): TRow[] | Promise<TRow[]>;
loadPage?(params: PageParams): Promise<PageResult<TRow>>;
capabilities?(): DataSourceCapabilities;
// ...
}

For a paginated list page, implement loadPage and capabilities. The grid calls loadPage whenever the user changes page, sort, filter, or search.

interface PageParams {
offset: number | string; // your `skip`
limit: number; // your `take`
query?: DataQuery; // sort / filters / search
}
interface PageResult<TRow> {
rows: TRow[]; // your `result`
totalCount: number; // your `count`
hasMore: boolean;
}

DataQuery is the grid-side representation of sort and filter state. Your adapter’s job is to translate it into whatever wire format your API accepts.

Step 1: write the adapter

A REST endpoint that takes skip, take, sort, q, and where[] query params and returns { result, count } translates like this:

import type {
DataSource,
DataSourceCapabilities,
PageParams,
PageResult,
FilterExpression,
ColumnSort,
} from 'strata-grid';
interface ListResponse<T> { result: T[]; count: number; }
function encodeSort(sort: ColumnSort[] = []): string[] {
return sort.map((s) => `${s.columnId}:${s.direction}`);
}
function encodeWhere(filters: FilterExpression[] = []): string[] {
// Flatten the FilterExpression tree into "field:op:value" strings.
const out: string[] = [];
const walk = (e: FilterExpression) => {
if (e.columnId && e.operator) {
out.push(`${e.columnId}:${e.operator}:${e.value ?? ''}`);
}
e.children?.forEach(walk);
};
filters.forEach(walk);
return out;
}
export function createListSource<T>(opts: { endpoint: string }): DataSource<T> {
return {
async load() {
// Fallback for non-paginated callers — unused when loadPage exists.
const res = await fetch(opts.endpoint).then((r) => r.json());
return (res as ListResponse<T>).result;
},
async loadPage({ offset, limit, query }: PageParams): Promise<PageResult<T>> {
const params = new URLSearchParams();
params.set('skip', String(offset));
params.set('take', String(limit));
encodeSort(query?.sort).forEach((s) => params.append('sort', s));
encodeWhere(query?.filters).forEach((w) => params.append('where[]', w));
if (query?.search) params.set('q', query.search);
const resp = await fetch(`${opts.endpoint}?${params}`);
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
const json = (await resp.json()) as ListResponse<T>;
const nextOffset = (offset as number) + limit;
return {
rows: json.result,
totalCount: json.count,
hasMore: nextOffset < json.count,
};
},
capabilities(): DataSourceCapabilities {
return { pagination: true, serverSort: true, serverFilter: true };
},
};
}

That’s the whole adapter — typically 60-90 lines once you handle auth, error mapping, and aborts. It’s reusable across every list page in your app.

Step 2: wire it into <DataGrid>

import { DataGrid, type ColumnDef } from 'strata-grid';
import 'strata-grid/styles.css';
import { createListSource } from './list-source';
const columns: ColumnDef<Supplier>[] = [
{ id: 'name', header: 'Name', accessor: 'name', filter: 'text', flex: 1 },
{ id: 'country', header: 'Country', accessor: 'country', filter: 'text' },
{ id: 'status', header: 'Status', accessor: 'status' },
];
export function SuppliersPage() {
const dataSource = useMemo(
() => createListSource<Supplier>({ endpoint: '/api/suppliers' }),
[],
);
return (
<DataGrid
data={[]} // ignored when dataSource is set
columns={columns}
dataSource={dataSource}
height={600}
pagination={{ pageSize: 50, mode: 'pages' }}
selection={{ mode: 'multi' }}
/>
);
}

That’s it. The grid feature-detects capabilities(), sees server-side sort and filter, and stops doing them client-side. Header clicks, filter inputs, and page changes all route through loadPage with the right DataQuery.

Step 3: round-trip view state through the URL

Sort, filters, search, column order, column sizes, and pinning all live in ViewState. Use defaultViewState to restore from URL on mount, and onViewStateChange to push changes back:

import { useSearchParams } from 'react-router-dom';
import type { ViewState } from 'strata-grid';
function viewStateFromUrl(params: URLSearchParams): Partial<ViewState> {
return {
searchTerm: params.get('q') ?? undefined,
sorting: params.getAll('sort').map((s) => {
const [columnId, direction] = s.split(':');
return { columnId, direction: direction as 'asc' | 'desc' };
}),
// ...filters, hiddenColumns, etc.
};
}
function viewStateToUrl(state: ViewState): URLSearchParams {
const params = new URLSearchParams();
if (state.searchTerm) params.set('q', state.searchTerm);
state.sorting.forEach((s) =>
params.append('sort', `${s.columnId}:${s.direction}`),
);
return params;
}
export function SuppliersPage() {
const [params, setParams] = useSearchParams();
const initialView = useMemo(() => viewStateFromUrl(params), []);
return (
<DataGrid
// ...
defaultViewState={initialView as ViewState}
onViewStateChange={(s) => setParams(viewStateToUrl(s), { replace: true })}
/>
);
}

ViewState is intentionally serializable JSON, so you can also persist it to localStorage for “remember my last view” behavior.

Step 4: row actions

Use the first-class rowActions prop (available from 0.2.0-alpha.0):

<DataGrid
// ...
rowActions={{
display: 'inline', // or 'menu' for a kebab dropdown
pin: 'right',
actions: [
{
id: 'view',
label: 'View',
icon: <EyeIcon />,
onClick: (row) => navigate(`/suppliers/${row.id}`),
},
{
id: 'edit',
label: 'Edit',
icon: <EditIcon />,
onClick: (row) => openEditDialog(row),
disabled: (row) => row.status === 'archived',
},
{
id: 'delete',
label: 'Delete',
icon: <TrashIcon />,
onClick: (row) => confirmDelete(row),
visible: (row) => canDelete(row),
},
],
}}
/>

Per-row visibility (visible(row)) and disabled state (disabled(row)) are evaluated for every row. Keyboard navigation, ARIA semantics, and focus management are handled by the grid.

Escape hatch — custom cell renderer

If rowActions doesn’t fit your case (e.g., you need a custom layout, a bulk-action toolbar that swaps in on selection, or grouped submenus), drop back to a synthetic column with a cell renderer:

const columns: ColumnDef<Supplier>[] = [
// ...your data columns
{
id: '__actions__',
header: '',
width: 96,
pin: 'right',
cell: ({ row }) => <YourCustomActions row={row} />,
},
];

Both patterns are supported; rowActions is the more ergonomic default.

Step 5: status bar (count, selection, active filters)

Strata is intentionally headless about chrome above and below the grid — status bars, filter pills, bulk-action toolbars belong to your application shell, not the grid. The data you need is all reachable today.

For selectedIds, listen to onSelectionChange:

const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
<DataGrid
// ...
onSelectionChange={(s) => setSelectedIds(s.selectedIds)}
/>;

For sort, filter, search, and column state, listen to onViewStateChange.

For total count and loading state (available from 0.2.0-alpha.0), use the onPaginationChange callback:

const [pagination, setPagination] = useState({
totalCount: 0,
currentPage: 0,
pageSize: 50,
isLoading: false,
});
<DataGrid
// ...
onPaginationChange={setPagination}
/>;
// In your status bar:
<div>
{pagination.isLoading
? 'Loading…'
: `${pagination.totalCount.toLocaleString()} results · page ${pagination.currentPage + 1}`}
</div>;

The callback fires on initial load, on every page navigation, and whenever loading or total-count state changes. The full event payload:

{
currentPage: number;
pageSize: number;
totalCount: number;
totalPages: number;
isLoading: boolean;
hasMore: boolean;
error: Error | null;
}

Pre-0.2.0 workaround (still supported)

If you’re pinned to 0.1.x or want loading state localized to the adapter itself, expose a subscribe seam from your adapter:

export function createListSource<T>(opts: { endpoint: string }) {
let totalCount = 0;
let isLoading = false;
const listeners = new Set<(s: { totalCount: number; isLoading: boolean }) => void>();
const emit = () => listeners.forEach((fn) => fn({ totalCount, isLoading }));
const source: DataSource<T> = {
async loadPage(params) {
isLoading = true; emit();
try {
const result = await fetchPage<T>(opts.endpoint, params);
totalCount = result.totalCount;
return result;
} finally {
isLoading = false; emit();
}
},
// ...
};
return {
source,
subscribe: (fn) => { listeners.add(fn); fn({ totalCount, isLoading }); return () => listeners.delete(fn); },
};
}

Both patterns are valid; onPaginationChange is simpler when the status bar lives outside the data-fetching layer.

Live example

The example below uses a mock in-memory adapter that simulates 250ms of network latency, with real server-style sort + filter + pagination over 137 synthetic supplier rows. Click headers to sort, change pages, select rows — every interaction round-trips through the mock loadPage.

0 suppliersserver-driven · 25 per page
ID
Name
Country
Status
Parts
No data
0 rows

What you get for free

Once the adapter is wired:

  • Row + column virtualization — visible only, regardless of result size.
  • Selection — single or multi, with onSelectionChange for bulk actions.
  • Column resize, reorder, pinning — built-in, persisted via onViewStateChange.
  • Quick search — set advancedFilter: { quickSearch: true } and the debounced search term flows into DataQuery.search.
  • Filter builder UI — set advancedFilter: { filterBuilder: true } for a builder surface that emits FilterExpression trees your adapter can encode.
  • Loading and empty states — built-in shimmer rows during page loads.
  • ARIA semanticsrole="treegrid" (or grid), proper keyboard navigation, screen-reader friendly.

Common pitfalls

  • Forgetting capabilities() — without it, the grid assumes client-side sort/filter and ignores your DataQuery. The header click will sort the current page locally, not the whole dataset.
  • Not memoizing the adaptercreateListSource({ endpoint }) returns a new object each render. Wrap it in useMemo([endpoint]) or the grid will reload on every render.
  • Passing data={someArray} and dataSource={adapter}data is ignored when dataSource has server capabilities, but type-checkers complain. Pass data={[]}.
  • Compound and/or filters in an API that’s flatFilterExpression supports compound trees, but if your API only accepts field:op:value strings (like ADR-026), you’ll need to either flatten lossily or extend your API contract.

What’s next

The pattern above ships in strata-grid today and is suitable for real list-page migrations. We’re collecting friction reports from teams running this in production — see the GitHub repo if you want to share what your adapter looked like or what you wished was first-class.

Shipped in 0.2.0-alpha.0:

  • onPaginationChange — surfaces { currentPage, totalCount, isLoading, hasMore, error } directly from the grid.
  • Typed column filtersfilter: { type: 'select' | 'boolean' | 'date' | 'text' | 'number', options?, multi?, range?, operators? } for constrained per-column UIs and emitted operators. See the typed filters guide.
  • rowActions prop — first-class actions column with inline / kebab display, conditional visibility, and built-in keyboard accessibility.

Planned (0.3.0+):

  • type: 'lookup' — async option loading for foreign-key columns.
  • type: 'custom' — escape hatch for fully custom filter UIs.
  • Discrete onSortChange / onFilterChange / onSearchChange — surgical callbacks beyond onViewStateChange.