Lattice Grid Buy a licence

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.

SourceFor
memoryThe default. Everything is present; the grid does all the work.
pagedA page at a time from a server that paginates.
remoteBlocks fetched on demand as the user scrolls, with sort and filter pushed to the server.
streamRows 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 },
})
CapabilityValuesMeans
filterfalse | 'term' | 'flat' | 'tree'Nothing, one field and term, a flat conjunction, or a full condition tree.
operatorsstring[]Which comparisons the engine genuinely applies. Declare only those it does.
sortfalse | 'single' | 'multi'No ordering, one column, or several.
quickbooleanWhether a free-text search across columns can be pushed.
rangebooleanWhether the engine can return a window rather than the whole result.
totalbooleanWhether it can report how many rows matched.
groupbooleanWhether 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

OptionDoes
unnestExpands an array property, one row per element, before anything else runs.
bucketRounds a date column down to a day, week, month, quarter or year and groups on that.
limitPerApplies limit within each value of a column rather than overall: a top three per region.
cumulativeKeeps rows until their running share of the total reaches a fraction: the Pareto head.
profileOne 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
  },
}
OptionDoes
onOne field name when both sides use it, or { left, right } when they differ.
typeinner keeps only rows that matched; left keeps them all.
selectWhich of the partner's fields to bring across. All of them by default.
prefixRenames the brought-across fields, for when both sides have a name worth keeping.
followWhich 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.