Server-side data
Everything on this site so far hands the grid an array and it does the work in the browser: a Web Worker sorts and filters, and a million rows stay interactive. That is the right answer for most applications, and it is what the Community edition does.
It stops being the right answer when the data is bigger than a browser tab, or
when it lives behind permissions that must not be evaluated on the client. For
that, implement a DataSource instead of passing an array. Filtering,
sorting, paging, distinct values and footer totals then run on your server,
and the component you render does not change.
Server-side data is an Enterprise feature. Passing an array to the Community
DataGridis unaffected — see Enterprise & licensing.
The contract
interface DataSource<TRow> {
load(options: LoadOptions): Promise<LoadResult<TRow>>;
byKey?(key: RowKey): Promise<TRow | undefined>;
loadDistinctValues?(options: DistinctValuesOptions): Promise<DistinctValue[]>;
}
Only load is required. The grid calls it whenever the query changes or it
needs a block of rows it does not hold.
import { EnterpriseDataGrid } from '@kanunilabs/datagrid-react-enterprise';
const orders = {
async load({ skip = 0, take = 100, sort, filter, searchText, summaries }) {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ skip, take, sort, filter, searchText, summaries }),
});
const data = await res.json();
return {
rows: data.rows,
totalCount: data.total, // the whole filtered result, not this page
summaries: data.totals, // optional; see "Totals" below
};
},
};
<EnterpriseDataGrid
dataSource={orders}
rowKey="id"
columns={columns}
filterRow
statusBar
/>;
totalCount is the size of the entire filtered result, not of the block you
just returned. It is what sizes the scrollbar, so a wrong number here shows up
as a grid that scrolls into empty space.
What arrives in load
| Field | What it is |
|---|---|
skip / take | The block being asked for. |
sort | Column ids and directions, in priority order. |
filter | A serializable filter AST — the same tree the worker gets. |
searchText | The quick-search box, if the grid has one. |
group / groupKeys | Remote grouping; see below. |
summaries | Footer totals to compute over the whole result. |
The filter is an AST rather than a string on purpose: it survives
JSON.stringify, so the same value can travel to a Web Worker or to your
server, and you translate it into SQL (or whatever you speak) once.
Distinct values — the header filter checklist
The header filter popup is a checklist of every distinct value in a column. A client-side grid derives that from the data it holds. A server-side grid cannot: it holds a few blocks, and a checklist built from "whatever happens to be cached" is worse than no checklist — a user would tick four values believing they were all of them.
So the popup is offered only when you implement loadDistinctValues:
async loadDistinctValues({ columnId, limit }) {
const rows = await db
.selectDistinct(columnId)
.orderBy(columnId)
.limit(limit); // apply the limit in SQL, not after
return rows.map((r) => ({ value: r[columnId] }));
}
Without it the grid falls back to the filter row, which needs no list.
Totals
Ask for footer totals by reading options.summaries and returning
result.summaries, keyed "columnId:type":
// options.summaries → [{ columnId: 'revenue', type: 'sum' }]
return {
rows,
totalCount,
summaries: { 'revenue:sum': 1_284_500, 'id:count': 8_412 },
};
Two things worth knowing:
- A custom aggregation never reaches you.
type: 'custom'is a JavaScript function on the client, and your server has no way to run it. A grid whose only aggregation is custom therefore sends nothing and shows an empty footer on a remote source — the honest answer rather than a wrong number. - Totals belong to the query, not to a block. Returning them on every
response is fine (the last wins), and so is returning them only when
skip === 0. What must not happen is answering a new filter with the old filter's totals; the grid discards what it holds the moment the query changes precisely so a stale number cannot sit under fresh rows.
Grouping on the server
With group set, groupKeys says which level is being asked for: its length
is the depth. Given group: [region, category]:
groupKeys: []→ return the regions, asgroupsgroupKeys: ['Berlin']→ return Berlin's categories, asgroupsgroupKeys: ['Berlin', 'Toys']→ return the data rows, asrows
A group row carries its own count and, optionally, its aggregates:
return {
totalCount: 12, // children at THIS level
groups: [
{ value: 'Berlin', count: 1_204, aggregates: { 'revenue:sum': 91_400 } },
],
};
count is the data rows underneath at any depth — what the header shows.
totalCount is the number of children at this level, which is what the grid
pages through. They are different numbers and mixing them up produces a group
that reports thousands of rows and scrolls twelve.
Typing while the server thinks
The filter row debounces on a remote source, so a five-letter search is one request rather than five. The grid also shows its loading overlay for anything slower than a moment, and cancels a request whose answer it no longer needs.
What stays client-side
Some things cannot cross the wire and remain the browser's job — they still work, they just do not become queries:
comparatorandexternalFilterare JavaScript functions, so a query using them falls back to the main thread.- Selection, focus, editing state and column layout are view state; they never round-trip.
- Export covers the whole filtered result, which means the grid asks the source for it — a large export is as slow as your endpoint is.
Local or remote, same component
Nothing else in your code changes. Grouping, filtering, totals, selection,
theming and the whole event surface behave the same either way, so a grid can
start on an array and move to a DataSource when the data outgrows the tab —
without a rewrite.