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 popup is simply not offered. The filter row, if you turned it on, still works — it sends a condition, not a list of values.

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 {
  rows: [],                     // required by the type; a group level answers with `groups`
  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 (200 ms by default), so a word typed in one go is one request rather than one per letter. The grid also shows its loading overlay for anything slower than a moment. It cannot cancel a request already in flight — load is not handed an abort signal — but it drops any answer that arrives for a query that has since changed, so a slow early response never overwrites a newer one.

What stays client-side

Some things never become part of a query:

  • comparator and externalFilter are JavaScript functions, and a function cannot be sent to your server. On a remote source the grid does not apply them at all — the order and the rows are whatever load returns. Put that logic in the endpoint.
  • Selection, focus, editing state and column layout are view state; they never round-trip.

Export does reach the server: it covers the whole filtered result, so the grid pages through the source to build the file, and a large export is as slow as your endpoint is. It is capped at 100,000 rows by default (remote.exportMaxRows); above that the export fails with a message naming the limit instead of writing a truncated file.

Local or remote, same component

Nothing else in your code changes. Grouping, filtering, totals, selection, theming and the event surface are the same props and the same events either way — the differences above are the exceptions — so a grid can start on an array and move to a DataSource when the data outgrows the tab — without a rewrite.