Documentation

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 DataGrid is 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

FieldWhat it is
skip / takeThe block being asked for.
sortColumn ids and directions, in priority order.
filterA serializable filter AST — the same tree the worker gets.
searchTextThe quick-search box, if the grid has one.
group / groupKeysRemote grouping; see below.
summariesFooter 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, as groups
  • groupKeys: ['Berlin'] → return Berlin's categories, as groups
  • groupKeys: ['Berlin', 'Toys'] → return the data rows, as rows

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:

  • comparator and externalFilter are 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.