developer guide
Sources, pushdown and derived grids
The memory, paged, remote and streaming sources, pushing the grid's query down to an engine, high-rate live updates, and grids built from other grids: derived, joined and cross-filtering.
Developer guide › Sources, pushdown and derived grids
Working with large data
A million rows in memory is fine: that is what the columnar store is for. Beyond that, or when the data lives behind an API, a source takes over.
| Source | For |
|---|---|
| memory | The default. Everything is present; the grid does all the work. |
| paged | A page at a time from a server that paginates. |
| remote | Blocks fetched on demand as the user scrolls, with sort and filter pushed to the server. |
| stream | Rows arriving over time, a query that streams, a socket. Promotes to memory once complete. |
A remote source
source: {
mode: 'remote',
pageSize: 100,
async fetch(req) {
const res = await api.rows({
offset: req.range.start,
limit: req.range.end - req.range.start,
sort: req.sort, // [{ col, dir }, …]
filters: req.filters, // the condition tree, as documented above
quick: req.quick,
}, { signal: req.signal }); // a superseded request aborts itself
return { rows: res.rows, total: res.total };
},
}
The request also carries groupBy, groupPath,
pivotBy, totals and your own context, so a server that
can group and aggregate does that work instead of the browser. Blocks are fetched as the
viewport reaches them and cached; changing the sort or the filter invalidates the cache and
re-queries.
The filters your callback receives is the same condition tree documented
above. You are not handed an opaque object to reverse-engineer, it is the published format,
and the same shape you would have written by hand.
Loading and updating data
Data usually arrives after the grid does. Build it empty, then load, the sort, filters, grouping and column layout you set up in the meantime all survive and apply to the new data.
The ordinary sequence
const grid = createGrid(el, { columns, rowKey: 'id', rows: [] });
grid.overlay.show('loading');
const data = await fetch('/api/circuits').then(r => r.json());
grid.rows.load(data);
grid.overlay.hide();
Incremental changes
rows.load replaces everything. When you have a delta, a websocket message, a
save that returned the updated record: apply just that.
Adds, updates and removals in one call
grid.rows.apply({
add: [{ id: 4, name: 'd' }, { id: 5, name: 'e' }],
update: [{ id: 1, name: 'A' }],
remove: ['3'], // row keys, or the row objects
at: 0, // optional insert position for `add`
});
// → { added: [...], updated: [...], removed: [...] }
An update is a patch. Fields absent from it are untouched, so a delta arriving from a websocket or coming back from a save can be applied as-is without reading the row first. This has to be said explicitly because the opposite: assigning the patch over the row: looks identical for a caller who happens to send whole rows and silently destroys data for one who does not.
Coalescing merges fields rather than keeping the last message. A feed
sending {price} and {volume} as separate messages inside one window
keeps both. Coalescing may reorder work; it may not lose it.
Flushing happens on a frame, with a timer behind it. A queued batch lands
on a paint boundary, which is what makes "ten thousand updates, one repaint" true rather than
usually true, a timer can fire twice between two paints. But
requestAnimationFrame is not guaranteed to fire at all: a backgrounded tab stops
firing it entirely. So a frame and a timer are armed together and the first to arrive wins. In
a foreground tab the frame always wins, at ~16ms against a 50ms fallback; in a hidden tab the
timer keeps the feed applying instead of the grid silently stalling with every caller's
promise unresolved.
A long flush defers rather than blocks. updates.budgetMs caps
how long one flush spends applying; over budget, the remainder returns to the queue and lands
next frame, and the promise a caller is holding resolves when their rows actually land rather
than when the first slice does. Slicing is by row and only for updates, a partially
applied row is not a state the store should be in, and splitting a structural change would
re-run the pipeline twice for one batch. stats().deferrals rising steadily means
the feed is arriving faster than the grid can apply it.
Rejections are reported, never thrown. Throwing would abandon the rows
that were fine. An update or remove naming a row that is not here is unknown-id;
an add whose key already exists is duplicate-id and is refused, because
selection, expansion, comments and the key index all resolve one key to one row and admitting
a second corrupts every one of them at once.
This runs the minimum pipeline. An update touching no sorted, filtered or grouped column skips those stages entirely and only the totals and the affected cells refresh. Adds and removals are structural and re-run everything.
Removals tombstone in place rather than compacting, so every existing index stays valid, which is what lets selection, expansion state and cached permutations survive a delete.
Patching a single cell
When you have a value rather than a row, setCells patches fields in place.
One cell, or many, without row objects
grid.edit.setCells([{ key: 'CIR-100042', colId: 'capacity', value: 990 }]);
grid.edit.setCells([
{ key: 'CIR-100042', colId: 'notes', value: 'Chased' },
{ key: 'CIR-100043', colId: 'capacity', value: 770 },
]);
// → the number of cells written
This is the full path, not a shortcut: it validates, emits cell:changed,
re-sorts if the column is sorted on, records one undo entry, and returns 0 for a
column the user may not write.
High-frequency updates
For a ticking feed, queue batches changes to the next animation frame so a
thousand messages a second produce sixty repaints rather than a thousand.
A price feed
socket.on('tick', (row) => grid.rows.queue({ update: [row] }));
Walking the data rather than the view
rows.forEach walks what is on screen: filtered, sorted, grouped, with collapsed
rows left out. That is the right default, and the wrong answer for a caller totalling a column,
exporting, or reconciling against another system.
let total = 0;
grid.rows.forEach(r => { total += r.data.amount }); // what the user can see
grid.rows.forEachAll(r => { total += r.data.amount }); // what the grid holds
forEachAll visits leaf rows only, in the order they arrived. Group rows are a
product of the current grouping and do not exist in the data, so they are not offered; the sort
belongs to the filtered view, so the order here is physical rather than sorted.
A remote or paged source holds the page it has fetched, not the whole set, so there is nothing there to walk past the filters. It warns and walks what it has rather than quietly returning the filtered rows, a caller who asked for everything and silently received a subset gets a number that looks entirely plausible and is wrong.
Holding live updates
A pause button for incoming data. Changes are held and merged while paused, applied when play is pressed, and counted throughout, so the coalescing that makes a live grid fast is finally visible.
Pause, play, and the counters
grid.updates.pause();
grid.updates.resume(); // apply everything held
grid.updates.flush(); // apply what is waiting, stay paused
grid.updates.stats();
// { paused, pending, queued, coalesced, coalescedTotal, rows, dropped, flushes, span }
grid.updates.log({ since: Date.now() - 60000 }); // what arrived, in order
Pausing is a button, not a guess. Inferring it from whether the user looks busy sounds friendlier and is wrong in both directions: too broad and a mouse resting on the grid freezes the feed until someone reloads, too narrow and rows jump the instant somebody stops moving in order to read. An explicit control has no heuristic to get wrong and no invisible state to explain.
Merging continues while paused, so a long pause costs one entry per
changed row rather than one per update. Forty updates to one row are one row of work when
play is pressed, and coalesced is the thirty-nine, which is the number nobody
could see before.
Bounding the rows themselves is separate. The log bounds change
history; a streaming source also needs to bound row retention, or a grid
left up overnight holds every row it was ever sent. Set source.maxRows and the
stream becomes a sliding window, dropping the oldest as new ones arrive and reporting how
many it let go through evicted on the progress report.
The log keeps the raw sequence, not the merged one. Merging is right for
applying a backlog quickly and wrong for looking at what happened, because the intermediate
states are exactly what a time scrubber would move between. It survives the flush,
pending is what is waiting, the log is what happened, and it is capped, so a
grid paused over lunch holds the recent past and reports how much it dropped rather than
taking the tab with it.
Querying an engine directly
A remote source hands you the whole request and leaves the translation to you. A pushdown adapter inverts that: you declare what your engine can answer, and the grid works out what to send and finishes the rest itself.
An OData endpoint, with nothing to write
import { createPushdownSource, odataAdapter } from '@toclocoinc/lattice-grid';
createGrid(el, {
columns,
rowKey: 'id',
source: createPushdownSource({
adapter: odataAdapter({ url: 'https://services.odata.org/V4/Northwind/Northwind.svc/Orders' }),
pageSize: 100,
}),
});
Four adapters ship. odataAdapter writes $filter,
$orderby, $top and $skip, and follows
@odata.nextLink when the server pages on its own terms.
restAdapter covers an ordinary JSON endpoint whose parameter names are yours to
give. dfqlAdapter speaks DemandFlow's query API. duckdbAdapter
takes a live DuckDB connection.
Declaring what an engine can do
No real engine answers the whole query. An adapter says what it can take, and everything undeclared stays with the grid.
An endpoint that pages and sorts, but does not filter
restAdapter({
url: '/api/readings',
params: { offset: 'from', limit: 'size', sort: 'orderBy', order: 'dir' },
capabilities: { sort: 'single', range: true, total: true },
})
| Capability | Values | Means |
|---|---|---|
| filter | false | 'term' | 'flat' | 'tree' | Nothing, one field and term, a flat conjunction, or a full condition tree. |
| operators | string[] | Which comparisons the engine genuinely applies. Declare only those it does. |
| sort | false | 'single' | 'multi' | No ordering, one column, or several. |
| quick | boolean | Whether a free-text search across columns can be pushed. |
| range | boolean | Whether the engine can return a window rather than the whole result. |
| total | boolean | Whether it can report how many rows matched. |
| group | boolean | Whether it can group and aggregate. |
Everything is off unless declared. An adapter that declares nothing still works: the grid fetches and does all the work itself. That is the safe direction to be wrong in. Declaring an operator the engine does not really apply is the unsafe one, because the grid will trust it and stop checking.
Splitting a filter is not symmetric. An and group narrows
with each condition, so the supported conjuncts go to the engine and the rest stay behind:
the engine returns a superset and the grid narrows it. An or group widens with
each branch, so pushing only the supported branches would return fewer rows than the filter
allows, and the grid cannot recover rows that were never fetched. A disjunction that is not
fully supported therefore stays whole on the client. The same asymmetry governs column
pruning in the facet path.
Residual work needs the whole result. When anything is left over, the source stops asking for windows and asks for everything, applies the remainder, and pages from what it holds. Filtering a window on the client is not a slower route to the right answer, it is a fast route to a wrong one: the rows that belong on page one may sit on page nine, and the total is whatever the engine happened to count.
Seeing what was pushed
The split is reported rather than hidden, which is the difference between a slow query you can diagnose and a slow query you cannot.
Asking after the last request
const source = createPushdownSource({ adapter });
createGrid(el, { columns, rowKey: 'id', source });
const plan = source.lastPlan();
plan.pushed; // the query the adapter was given
plan.residual; // { filters, sort, quick } the grid applied after
plan.unpushed; // ['filter'], the parts that stayed behind
plan.needsAll; // true when the whole result had to be fetched
The grid also warns once, naming the predicate that could not be pushed, because the fix is
usually a better adapter rather than a bigger machine. It warns again if an adapter reports a
total larger than the rows it returned while residual work is outstanding: that
combination silently produces wrong answers, and it is worth knowing about.
A full analytical engine, without carrying one
duckdbAdapter takes a connection you created and imports nothing, so the grid
can drive DuckDB while this package stays at zero dependencies.
Parquet in the browser, no server
const db = await makeDuckDB(); // yours: @duckdb/duckdb-wasm
const conn = await db.connect();
createGrid(el, {
columns,
source: createPushdownSource({
adapter: duckdbAdapter({
connection: conn,
from: "read_parquet('readings.parquet')",
}),
}),
});
from is any FROM expression, so
read_parquet('s3://bucket/*.parquet') is as valid as a table name. Values are
bound through prepared statements; a connection without prepare is used only for
unfiltered queries, because interpolating a user's filter into SQL is the one thing worse
than not filtering at all.
Column and table names are checked against an identifier pattern rather than escaped, and a name that fails is refused. Integers past the safe range are kept as strings instead of being rounded into a plausible lie.
demo/duckdb.html runs this against a Parquet file of several million readings
with no server involved.
Grids built from other grids
A derived grid takes its rows from another grid rather than from a load: grouped and aggregated, unnested, filtered, ranked or profiled. It has its own element and its own columns, and it follows its source live.
A summary panel beside the detail grid
const detail = createGrid(left, { columns, rowKey: 'id', rows });
createGrid(right, {
columns: [
{ id: 'region', header: 'Region' },
{ id: 'total', header: 'Capacity', type: 'number' },
{ id: 'n', header: 'Sites', type: 'number' },
],
source: {
mode: 'derived',
from: detail,
groupBy: 'region',
select: {
total: { of: 'capacity', fn: 'sum' },
n: { fn: 'count' },
},
sort: [{ col: 'total', dir: 'desc' }],
},
});
follow chooses which of the source's rows are read:
filtered by default, or all, selected or
grouped. The pipeline runs unnest, then join, then
where, then bucket and groupBy, then
select, then sort and limit, so a condition or a total
can read a field that an earlier stage produced.
A change is patched, not re-derived. When a row changes in the source, the
derived grid updates the groups that row belongs to rather than rebuilding the lot. Five
hundred updates against a two hundred thousand row source cost under 300 ms in total. Set
refresh to live, manual or a number of milliseconds to
change the coalescing; idle is the default and settles to a frame.
Derived grids are read-only. There is one copy of the data and it lives in the source. Write there and the derived grid follows.
The key comes for free. A derived grid keys on __key, which
the source writes onto every row it produces: the group value, the profiled column, or the
source row's own key when nothing is grouped. Set rowKey only to override it.
Other shapes
| Option | Does |
|---|---|
| unnest | Expands an array property, one row per element, before anything else runs. |
| bucket | Rounds a date column down to a day, week, month, quarter or year and groups on that. |
| limitPer | Applies limit within each value of a column rather than overall: a top three per region. |
| cumulative | Keeps rows until their running share of the total reaches a fraction: the Pareto head. |
| profile | One row per column with the statistics as columns, or one row per statistic with orient: 'metrics'. |
Joining two grids
Two grids holding their own data, and a third showing where they meet. Both sides stay live.
Bringing an owner's fields across
source: {
mode: 'derived',
from: sites,
join: {
with: owners,
on: { left: 'ownerId', right: 'id' },
type: 'left',
select: ['name', 'tier'],
prefix: 'owner', // owner.name, owner.tier
},
}
| Option | Does |
|---|---|
| on | One field name when both sides use it, or { left, right } when they differ. |
| type | inner keeps only rows that matched; left keeps them all. |
| select | Which of the partner's fields to bring across. All of them by default. |
| prefix | Renames the brought-across fields, for when both sides have a name worth keeping. |
| follow | Which of the partner's rows to read: all by default, or filtered. |
A left join is usually the one you want. An inner join quietly drops the
rows that did not match, and those are often the finding: the site with no owner, the payment
with no invoice. left keeps them visible with the partner's fields empty, so the
gap is something you can see and sort by rather than something you have to notice is
missing.
First match wins. The join is a lookup, not a cross product: a row on the left produces exactly one row out, so a grid of ten thousand rows stays a grid of ten thousand rows and cannot silently multiply.
Both sides are live. A change on either grid updates the join, and it is patched from whichever side changed rather than rebuilt.
Cross-filtering
A derived panel can filter the grid it summarises. Click a region in the summary and the detail grid narrows to it.
Click to filter, click again to release
source: {
mode: 'derived',
from: detail,
groupBy: 'region',
select: { total: { of: 'capacity', fn: 'sum' } },
crossFilter: true, // or a source column name
}
summary.events.on('rowClick', (e) => summary.crossFilter.toggle(e.key));
crossFilter.set, toggle, clear, get and
column make up the API. true filters through whatever the grid
groups by; a string names a different source column when the two do not share a name.
A panel does not filter itself. The filter a summary pushes onto its source is excluded when that same summary re-derives. Without that, clicking one region collapses the panel to the single row you just clicked, and there is nothing left to click next. With it, the panel keeps its full set of regions with the chosen one marked, which is what makes a second click possible at all.
Several panels compose. Each pushes its own filter onto the shared source and each excludes only its own, so region and status narrow the detail together while both panels stay navigable.
Diagnostics and devtools
Data grids fail in ways that are hard to diagnose from outside. This is the grid saying what it is actually doing.
Asserting on DOM writes
const before = grid.diagnostics.renders().dom.cellWrites;
await doTheThing();
expect(grid.diagnostics.renders().dom.cellWrites - before).toBeLessThan(200);
The API came first and the panel second, on purpose. Instrumentation written behind a UI gets shaped by the layout: it reports what is convenient to display rather than what is true, and it cannot be tested. An API that stands on its own can be asserted against, and the assertion above is not one most grids can support.
Most of it already existed inside the grid. The renderer had counted cell writes, row updates and paints all along; the column store could already report a real byte footprint per column, summing backing array, presence bitset and dictionary. Neither was reachable from the public API. Exposing what a system already knows is usually a better first move than measuring something new.
Warnings are mostly collection, not detection. The grid has 160 places that warn once per cause, each already carrying a stable de-duplication key, which is exactly the stable identifier a support conversation needs. They went to the console and nowhere else. The console interleaves with your own logging, does not survive a reload, and cannot be asked what it has already complained about. They are now kept as records too.
Every warning names values, not just a condition. "Something is slow" is a warning nobody can act on. Each carries the specific numbers, a stable id, and is dismissible for the session but not permanently, a permanently dismissible warning is one nobody sees again after the person who dismissed it leaves.
The checks are tested for silence as much as for detection. A clean grid
must raise nothing. A checker that cries wolf is one developers learn to ignore, and then it
is worth less than no checker at all. The accessibility checks found two false positives in
themselves during development: first comparing aria-rowcount against the row
count when ARIA counts header rows too, then counting header row *elements*, of which a
single-level header has three because the header is built once per pinned region.
Instrumentation must not change what it measures. Counters are integers
incremented where the work already happened. Render phases are four performance.now()
marks around existing sections. Timings are sampled, a bounded window of recent operations,
and every report names which of its figures are sampled, because a number whose provenance is
unclear is worse than no number. Paint wait is the browser's and is deliberately not claimed.
Render causes are captured, not inferred. By the time a paint runs, several distinct causes have collapsed into the same dirty flags, so working backwards gives a plausible answer rather than a true one. The renderer records the structural reason when the invalidation arrives; the semantic one (filter, sort, data) is only knowable from the event that preceded it, so the DOM layer supplies that and the structural reason is the fallback.
Providers are wrapped where they are installed, not at each call site, so one added later is instrumented by construction. The wrapper returns exactly what the original returned and re-throws exactly what it threw. Failures are kept, because a provider failure is usually swallowed by the host application's own error handling before a developer sees it, and "the grid is behaving strangely" is where that conversation otherwise starts.
The heat overlay uses two colours because there are two findings. A cell written with a value it did not hold is work the grid had to do. A cell rewritten with the value already in it is work it did not. Only the second is waste, and a counter alone will never tell you where it is. The overlay records a baseline when switched on, because otherwise the first paint has nothing to compare against and tints nothing, which reads as broken rather than as empty.
The module imports nothing. Not the grid, not a shared helper. The bundler
inlines whatever a module imports: the framework adapters stay small because
createGrid is handed to them, while the web component carries the grid with it
because it imports it. A devtools module that imported anything from core would put the whole grid inside a
bundle whose entire promise is that deployments not using it pay nothing.
The support bundle carries no row data. Configuration, query state, timing, warnings, provider statistics, version and environment, and nothing from your data. Stated as a guarantee because a bundle that had to be inspected for confidential values before sending is a bundle that never gets attached to the ticket.
It observes and never mutates. A configuration editor in a debug panel is tempting and would create a second path into grid state that has to be kept correct forever. No telemetry: nothing leaves the browser unless you export a bundle yourself.