Where client-side sorting and filtering stop working
A Web Worker gets you a million interactive rows in the browser. It does not get you a dataset bigger than a browser tab, or one where a user is only allowed to see some of the rows. That is a different problem with a different contract.
KanuniLabs7 min readMove sorting and filtering off the main thread and a million rows stop freezing the page. That fix has a limit built into it, though, and the limit isn't a row count — it's the word "array." Everything a Web Worker does still starts from data the browser already has. Two situations put you on the other side of that line regardless of how fast the sort is:
- the dataset does not fit in a browser tab — ten million rows, a table your backend paginates for its own reasons, a report nobody has ever tried to download in full;
- a row-level permission has to be evaluated somewhere a user cannot open devtools and read the response — which rules out sending all the rows to the client and filtering client-side, no matter how fast that filter runs.
Both of these mean the grid can no longer be the thing that decides which rows exist. Something on your server has to do that, and the grid has to ask it, every time the query changes.
The shape of the contract
Instead of an array, you hand the grid an object with a load method:
interface DataSource<TRow> {
load(options: LoadOptions): Promise<LoadResult<TRow>>;
byKey?(key: RowKey): Promise<TRow | undefined>;
loadDistinctValues?(options: DistinctValuesOptions): Promise<DistinctValue[]>;
}
load is the only required piece. The grid calls it whenever the query
changes, or when it scrolls into a block of rows it isn't holding yet:
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 below
};
},
};
<EnterpriseDataGrid dataSource={orders} rowKey="id" columns={columns} filterRow statusBar />;
totalCount is worth getting right early: it is what sizes the scrollbar,
so a wrong number here shows up as a grid that scrolls confidently into
empty space, which is a strange bug to debug from the symptom alone.
The filter your endpoint receives is the same AST the header filter row
and the filter builder produce — not a
pre-built SQL string, because you're the one who knows how to turn { logic: 'and', nodes: [...] } into a WHERE clause for your own schema, and
because an AST survives JSON.stringify in a way a query object with
functions inside it does not.
The part that breaks quietly if you skip it: distinct values
The checklist inside a header filter popup — "every value this column contains, tick the ones you want" — is easy for a client-side grid: it already holds every row, so it just reads the unique values off them. A server-side grid does not have that option. It holds whatever blocks of rows happened to load, and building a checklist out of "whatever is cached" is worse than not offering a checklist at all — a user ticking four values, believing that's all of them, and getting a filter that silently excludes a fifth value nobody showed them.
So the popup only appears when you implement loadDistinctValues:
async loadDistinctValues({ columnId, limit }) {
const rows = await db.selectDistinct(columnId).orderBy(columnId).limit(limit);
return rows.map((r) => ({ value: r[columnId] }));
}
Skip it, and the grid does not fake a checklist from partial data — the popup just isn't offered. The filter row still works if you've turned it on, because it sends a condition rather than a list of values.
Totals, and the one aggregation that can't cross the wire
Footer totals work the same way: the grid tells you what it needs, keyed by column and aggregation type, and you return numbers computed over the whole filtered result, not the page you just sent.
// options.summaries → [{ columnId: 'revenue', type: 'sum' }]
return {
rows,
totalCount,
summaries: { 'revenue:sum': 1_284_500, 'id:count': 8_412 },
};
One aggregation type genuinely cannot reach you: type: 'custom' is a
JavaScript function that lives on the client, and there is no way to ship a
function over JSON.stringify for your server to run. A grid whose only
aggregation on a column is custom sends nothing for it and shows an empty
footer cell on a remote source. That's the honest answer — a grid that
guessed a number there would be worse than one that shows nothing.
The other detail worth knowing before it surprises you: totals belong to
the query, not to a page of it. Returning them on every response is fine.
Returning them only when skip === 0 is also fine. What breaks is
answering a new filter with the old filter's totals — which is why the grid
discards what it's holding the moment the query changes, so a stale number
can never sit under fresh rows on screen.
Grouping without holding the tree
Row grouping asks the same question at every level instead of asking for
the whole tree at once. group says which columns to group by; groupKeys
says which node in that tree is being opened, and its length is the depth.
Given group: [region, category]:
groupKeys: []→ return the regions, asgroupsgroupKeys: ['Berlin']→ return Berlin's categories, asgroupsgroupKeys: ['Berlin', 'Toys']→ return the actual data rows, asrows
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 and totalCount answer different questions and it's easy to swap
them by accident: count is how many data rows sit underneath, at any
depth — the number the group header displays. totalCount is how many
children this level has, which is what the grid paginates through if a
group has more subgroups than fit on screen. Mixing them up produces a
group row that proudly reports a few thousand rows and then only scrolls
through twelve of them.
What never becomes a request
A few things never reach your endpoint, and it's worth knowing which:
comparatorandexternalFilterare JavaScript functions, and a function can't be sent to your server. On a remote source the grid doesn't apply them at all: the order and the rows are whateverloadreturns. A permission rule belongs in the endpoint anyway — that was the point of moving there.- Selection, focus, editing state and column layout are view state. They never round-trip to the server — there is no reason they should.
- Export does reach it. It covers the whole filtered result, so the
grid pages through your
DataSourceto build the file, and a large export is as slow as your endpoint is for that query. There's a cap, 100,000 rows by default: above it the export fails with a message naming the limit, rather than quietly writing a truncated file.
The filter row also debounces against a remote source (200 ms by
default), so a word typed in one go becomes one request rather than one
per letter. The grid can't cancel a request already in flight — load
isn't handed an abort signal — but it drops any answer that arrives for a
query that has since changed, so the last keystroke wins, not the last
response to arrive.
Same component, later decision
Moving from an array to a DataSource doesn't change the component you
render: grouping, filtering, totals, selection, theming and the event
surface are the same props and the same events either way, with the
differences above as the list of exceptions. That's deliberate — it means
the decision about where your data lives doesn't have to be made on day
one. A grid can
start on an array while the dataset is small, and move to server-side
paging the day it stops being small, without becoming a different
component to integrate.
Server-side data is an Enterprise feature;
passing a plain array to the Community DataGrid is unaffected either way.
The full DataSource contract, including sort priority order and remote
grouping, is on the server-side data page.