Lattice Grid Buy a licence

api reference

Rows, columns and data sources

grid.rows and the transaction API, grid.columns, the source modes, and the live-update stats.

API reference › Rows, columns and data sources

grid.rows

Data usually arrives after the grid does. Build it with rows: [], then load when your fetch resolves, the sort, filters, grouping and column layout you set up in the meantime all survive, and apply to the new data.

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);          // replaces whatever was there
grid.overlay.hide();
MethodReturnsDescription
load(rows)voidReplaces the data. The view (sort, filters, grouping, column layout) is kept. Same as grid.set('rows', data).
get(index)RowBy display index, after filtering, grouping and flattening.
byKey(key)RowBy row key, whether or not it is on screen.
matchCount()numberData rows passing the filters, across every page. Excludes group headers, footers and totals, the numerator of "1,204 of 100,000".
count()numberDisplay rows: group rows included, collapsed children excluded.
totalCount()numberSource rows before filtering. The denominator of "1,204 of 100,000".
value(key, colId)unknownThe stored value.
text(key, colId)stringThe formatted display text.
values(key)objectEvery column's value for one row.
data()unknown[]The caller's original row objects, in source order.
forEach(fn)voidWalks display rows without materialising them all.
forEachAll(fn)voidWalks every row in the data, before any filter: leaf rows only, in the order they arrived. What you want for a total, an export or a reconciliation, where forEach would give you the view instead. A remote or paged source holds only what it has fetched and says so.
forEachExcept(colId, fn)voidWalks the rows surviving every filter except that column's own, the faceting question, asked of the rows. It is what lets a header histogram keep every bar after one is clicked, and what lets a cross-filtering panel avoid narrowing itself out of existence. Needs a memory source; anything else falls back to the filtered rows and warns.
apply(change)objectTransactional add / update / remove. Needs rowKey.
queue(change)voidBatches a change into the next frame, the high-frequency path.
refresh(opts)voidForce re-evaluation of computed values and cells.
move(key, to){ moved, from, to, reason? }Move a row to another position in the data. Refuses, naming the reason, while a sort, filter or grouping is active. Emits row:moved; persisting the new order is yours.
groupHeadings(index)Row[]The group rows enclosing a display index, outermost first. Empty when the grid is not grouped. Useful for a breadcrumb of your own.
expand(key, deep?)void
collapse(key)void
expandAll() / collapseAll()void

grid.columns

MethodReturnsDescription
all()ResolvedColumn[]Every column, hidden included.
visible()ResolvedColumn[]In render order, including generated group and pivot columns.
get(id)ResolvedColumn
show(ids) / hide(ids)void
move(id, to)voidIndex into the full column order.
pin(id, side)void'start', 'end' or null.
resize(id, px)void
autoSize(ids)voidFit each column to its rendered content.
fit()voidDistribute all columns across the viewport.
group(ids)voidSet the row-group columns, in order.
pivot(ids)void
totals(ids)voidWhich columns carry an aggregation.
setTotal(id, fn)voidChange one column's aggregation. null stops totalling it.
distinct(id)unknown[]Distinct values, read from the dictionary rather than by scanning rows.
state()ColumnState[]Serialisable column state.
apply(state)StateApplyReportRestore it. Never throws and never refuses: a saved view written against an older column set applies as much of itself as still makes sense, and the returned { applied, skipped } names what it could not use and why. Columns added since the view was saved appear in their declared state, after the ones it names. See Saved views.

Sources

Where rows come from. memory is the default and needs no configuration.

ModeNeedsDescription
memoryrowsEverything is present. The grid filters, sorts, groups and totals it.
pagedfetchA page at a time from a server that paginates.
remotefetchBlocks fetched as the viewport reaches them, with sort, filter and grouping pushed to the server.
streamconnectRows arriving over time. Promotes to memory once complete.
derivedfromRows built from another grid: grouped, unnested, filtered, ranked or profiled. Read-only, and follows the source.

Derived sources: a grid built from another grid

Most dashboards put a summary panel beside a table, the top five sales people, the breakdown by region, the exceptions list. Built by hand, that panel runs its own query, and sooner or later somebody filters the table and the panel does not follow. Everyone who has shipped a dashboard has been in the meeting where two numbers on one screen disagree.

A derived grid removes the possibility. It is a second grid whose rows are built from the first (grouped, unnested, filtered, ranked or profiled) so the panel is the table, one derivation later, and one filter moves both. It answers the questions a summary panel exists for: the top five sales people, the most-sold SKUs, a statistical profile of whatever the user has filtered to.

It is a source rather than a new kind of grid, so everything downstream: its own sorting and filters, totals, shadow columns, formatting, export, themes: works on the result and knows nothing about where the rows came from. Charts bind to one as readily as to any grid.

source: {
  mode: 'derived',
  from: salesGrid,
  follow: 'filtered',

  groupBy: 'rep',
  select: { revenue: { of: 'amount', fn: 'sum' }, deals: { fn: 'count' } },
  sort: [{ col: 'revenue', dir: 'desc' }],
  limit: 5,
}

The pipeline runs in one order, and the order is the contract: unnest → where → bucket → group → reduce → sort → limit. where sits before grouping deliberately: filtering afterwards is a different question, which groups, not which rows, and one key cannot mean both.

KeyTypeDescription
fromGridRequired. The grid to read.
follow'filtered' | 'all' | 'selected' | 'grouped'Which of its rows to read. filtered by default. grouped re-aggregates by whatever dimension the user has grouped the source by, so a panel tracks the reader rather than a dimension fixed when the page was built; with the source ungrouped it falls back to groupBy.
unneststringExpand an array property, one row per element, keeping the parent's fields. Address the element with a dotted path afterwards: lines.sku is the element, region is still the parent. A row whose property is absent or empty contributes nothing.
join{ with, on, type, select, prefix, follow }Match each row against a second grid on a shared key and bring some of its fields across. Runs after unnest and before where, so a condition (and a grouping, and a total) can read a field the join produced.
where(row) => booleanA row predicate, applied before grouping. With no groupBy the rows pass through as themselves, which is how an exceptions list is built.
bucket{ of, by }Round a date column down to the start of its period and group on that. by is day, week, month, quarter or year; weeks start on the ISO Monday.
groupBystring | string[]The dimension, or dimensions, to group by. Omit to pass rows through.
selectRecord<string, {of, fn}>The reduced columns, by output id. fn is any key of the totals-row kernels, so median, p95, stddev and gini are available as readily as sum. count needs no of.
sort{ col, dir }[]Order the derived rows before limiting them. The grid's own user-facing sort is separate and unaffected.
limitnumberKeep at most this many rows.
limitPerstringApply limit within each distinct value of this column rather than overall, the best three SKUs in each region, which a global limit cannot express.
cumulative{ of, upTo }Keep rows until their running share of the total reaches upTo, 0 to 1. The Pareto question. The row that crosses the cutoff is kept, because the set has to reach the share.
profilestring | string[]Replaces the pipeline with a transpose: one row per column, with count, present, missing, distinct, min, max, mean, median, quartiles, deviation and outlier count as its columns.
orient'columns' | 'metrics'With profile, emit one row per statistic instead of one per column, the shape a dashboard tile wants.
crossFilterboolean | stringLet this grid filter the grid it derives from. true cross-filters through whatever it groups by; a string names a different source column.
refresh'live' | 'idle' | 'manual' | numberWhen to re-derive. idle by default, coalescing to a frame, because a hundred cell updates in one frame are one derivation. A number debounces by that many milliseconds.

Read-only. A derived row is an answer, not a record: there is no write-back for the sum of four hundred rows, so writes are refused with a reason rather than accepted and discarded on the next refresh.

They chain. A derived grid can be the source of another to any depth: a profile of the top five, and a change at the root travels the whole chain. A cycle is refused rather than recursed.

The key. rowKey defaults to the derived key and need not be set. It is the group value, which is what makes a live ranking readable: the row moves rather than the values under it changing.

Cost. The first derivation is linear in the rows read and largely independent of what is reduced: roughly 900 ms per 200,000 rows grouped into forty, whether the selection is one sum or four statistics. After that, a change that names the rows it touched is patched rather than re-derived: only the groups those rows entered or left are reduced again, so a live feed costs time proportional to what changed rather than to the table. Five hundred updates against that same source take under 300 ms in total, not 300 ms each. A joined derivation is maintained the same way from both sides: the lookup is held between derivations rather than rebuilt, a change to the fact table rejoins only the rows that moved, and a change to the lookup rejoins only the rows behind the keys whose match actually changed, about 2 ms per fact update and 1.5 ms per lookup edit against a 200,000-row source joined to 2,000 customers. A change that cannot be reasoned about that way, a new filter, a regrouping, a derivation using unnest or where, or a lookup row arriving for rows an inner join had dropped, falls back to a full derivation, which is correct but costs the full linear pass. Narrow with follow: 'filtered' so the derivation reads what the user is looking at rather than the whole table.

What a change firing promises

Anything maintaining state from rows:changed (a derived grid, a chart, your own cache) needs to know whether a firing names the rows that moved or merely says that something did. One rows.apply announces itself more than once: the source reports how many rows moved, the row model reports which ones, and the grid reports that a change happened. Acting on all three does the work three times over.

FieldMeaning
identified: trueadded, updated and removed are arrays naming exactly the rows that moved. Safe to patch from.
companion: trueA second announcement of a change already reported with identity, or one made before the grid's own view caught up. Ignore it.
neitherA real change whose extent cannot be named: rows replaced wholesale, or a row moved, where what changed is the order. Re-read.

The default is the safe one. A firing that carries neither flag is treated as a change of unknown extent, so a listener re-reads rather than assuming nothing moved. Read the flags rather than the shape of the payload: a firing that reports a row move carries counts, because no row changed value, and only the flags distinguish that case from a duplicate announcement.

Confidence intervals: how much to trust the figure

Every other statistic here describes the data you have. An interval describes how well that data pins down the figure you actually care about, and it is the one thing a descriptive tool can honestly say about the world beyond its rows.

The line this draws. Lattice quantifies uncertainty; it does not adjudicate hypotheses. There are no p-values, no significance tests and no verdicts, and reading two non-overlapping intervals as a significance test is a mistake often enough to be worth not encouraging. An interval says "the mean is 42, and the sample pins that down to between 39 and 45". It does not say 42 differs from 40.

CallReturnsDescription
statistics.interval(colId)ConfidenceIntervalThe interval for a column's mean, using the t distribution rather than the normal: below about thirty readings the normal interval is noticeably too narrow, and at five it understates the width by roughly a sixth.
statistics.keyOf(data)string | nullThe key a row's data resolves to, without needing the row. What a caller holding raw data uses to reach the grid's view of it.
statistics.maintenanceRecord<string, string>Which reductions can be maintained against a change and which must rescan: sum and avg exactly, min and max only away from the extreme, median and the rest never. Ask before putting one in a footer over a million rows on a live feed: the difference is a totals row that costs nothing per tick and one that costs a full pass.
statistics.intervalOf(values)ConfidenceIntervalThe column-free form, for readings that are not a column, the rows behind one bar, a subgroup, a hand-assembled sample. One t-quantile serves the chart's whiskers and the panel's bounds alike.
statistics.interval(colId, { kind: 'proportion' })ProportionIntervalA Wilson score interval for a rate. where decides which rows count as successes; truthiness by default.
statistics.capability(colId).ruleSetstringWhich rule set produced violations. Named in the result because the two number their rules differently: “rule 3” means a trend under Nelson and four-of-five-past-one-sigma under Western Electric.
statistics.capability(colId).intervalCapabilityIntervalAn interval for Cpk, by Bissell's approximation, and intervalPp for Ppk.
slopeInterval(fit)objectAn interval for a regression slope, from the standard error regression already reports.

Every interval carries its level. The result's confidence field says what it was computed at, so a figure copied out of one cannot lose the thing that makes it readable. An interval without its level is not a smaller claim, it is an unreadable one.

A proportion is Wilson, not Wald. The textbook p ± z√(p(1−p)/n) fails exactly where a rate is most interesting: near zero it reaches below zero, and at no observed successes it collapses to the single point zero: claiming perfect certainty from the least informative sample there is. The Wilson interval stays inside 0 to 1 and stays sensible at the extremes, so "none of forty failed" correctly reads as "the failure rate is under 9%" rather than "the failure rate is zero".

Report the capability interval. Its absence is the commonest way a capability study overstates itself. A Cpk of 1.35 measured on thirty parts has a lower bound below 1.0, so a process that has "passed" a 1.33 requirement on thirty parts has demonstrated very little. The point estimate alone does not say that; the interval does.

It follows the filters. Like every statistic here, an interval reads the rows the filters left, so it narrows as the user narrows the grid. That is the correct behaviour and worth knowing: it describes the filtered population, not the whole table.

Reading an SPC chart

The figures are only half of it. A capability claim is made in a picture, and these are the two the discipline expects.

TypeShows
controlThe readings in order, with the centre line and control limits the process itself sets, the tolerance the customer set, and every rule break marked and numbered.
capabilityThe readings as a histogram with the tolerance drawn across them, and a fitted normal curve for each of the two spreads: short-term and overall.
movingRangeThe lower half of an I-MR pair: the gap between consecutive readings, against its own limits. Only the upper limit signals, because a range cannot be negative.

The lines are named, on opposite edges. CL, UCL and LCL at the right; LSL, USL and Target at the left. Control limits are what the process does; specification limits are what the customer asked for, and reading one as the other is the classic misreading of a control chart. A capable process puts its control limits just inside its tolerance, so the two families sit close together, which is exactly when they need telling apart, and why they are named at opposite ends rather than left to collide.

Rule breaks carry their number. With Western Electric's four rules a marked point was readable on its own; with Nelson's eight it is not. A spike (rule 1) is a bad part; a six-point trend (rule 3) is tool wear or a drifting sensor. They call for different responses, and a chart that marks both the same way has told you the less useful half of what it knows. Set the rule set with rules: 'nelson' on the chart, as on statistics.capability.

The capability report draws two curves, not one. Cp and Cpk are computed from short-term variation, Pp and Ppk from overall. When a process has drifted the two differ, and the four indices say so only as numbers a reader has to know how to compare. Drawn, the gap is the finding: a narrow solid curve inside a wide dashed one is a capable process that has been allowed to wander, a scheduling problem, not a machine problem.

What this covers, and what it does not. These are individuals charts: one reading per point, with short-term variation estimated from the moving range. That is the right instrument when readings arrive one at a time, a sensor, a test rig, a single-piece flow.

It is not the right instrument for subgrouped data, and the difference is not cosmetic. If you measure five parts an hour, the correct chart is X̄-R: its limits come from within-subgroup variation and are roughly √n tighter, which is what makes a shift in the process centre visible. Run an individuals chart over the same readings and the limits are computed from differences that mix within- and between-subgroup variation; they come out around twice as wide, and a two-sigma shift that X̄-R flags a dozen times over reads as scattered noise. Lattice does not ship X̄-R, X̄-S, or the attribute charts (p, np, c, u), and an individuals chart should not be substituted for them.

Pair the two control charts. An individuals chart asks whether the process has moved; a movingRange chart asks whether it has become less repeatable. A process can fail either without failing the other: it can drift while its point-to-point variation holds steady, and it can hold its average while shaking itself apart. The second is close to invisible on the individuals chart alone, because a wider spread pulls that chart's own limits wider with it: it rescales to accommodate the very thing that has gone wrong. Drawn one above the other, they are the standard I-MR pair.

Bind it to the readings, not to a summary of them. A whisker is computed from the values the chart can see behind each mark. Bound to a grid whose rows are already one per mark, a summary or a derived panel, the chart sees a single value per category and there is no spread to draw: the readings that produced each average are upstream and no longer reachable. Bind the chart to the rows the summary was computed from, or carry a margin yourself and use error: { of: 'margin' }. A chart asked for whiskers it cannot compute says so once rather than drawing nothing in silence.

Uncertainty on a chart. error: true draws a whisker on each mark, computed from the readings behind it. Four bars side by side invite a comparison the numbers alone cannot support, a five per cent gap between two categories of eight readings is noise, and between two of eight hundred it is the finding. The whisker is what tells them apart, and its absence is why bar charts are so often over-read. A mark with a single reading gets none, because one reading has no spread and a zero-height whisker would claim certainty rather than admit ignorance.

Fitted lines. fit: true draws a least-squares line through a scatter with its R² beside it; fit: 'line' draws the line alone. A cloud of points invites a reader to draw the line themselves, and people are consistently poor at it, the eye is pulled by the extremes, which is exactly what least squares is not.

The interval travels with the index. The statistics panel shows the bounds under Cpk and Ppk, and createStat takes an interval function that puts them under the value. A tile is where a figure is read fastest and questioned least, which makes it the place an interval earns its keep rather than the place it is least needed.

Pushdown adapters: one query, many engines

A remote source already receives a structured request: range, sort, filters, quick text, grouping, pivoting and totals. A pushdown adapter turns that request into whatever an engine speaks, so connecting a new back end is a translation layer rather than a new source.

import { createPushdownSource, odataAdapter } from '@toclocoinc/lattice-grid';

const source = createPushdownSource({
  adapter: odataAdapter({ url: 'https://api.example.com/Orders' }),
  compute,
});

createGrid(host, { source, columns: [...] });

An adapter never carries an engine. Each one takes what it needs as a parameter: restAdapter takes a fetch and bundles no HTTP library, dfqlAdapter takes a token, and duckdbAdapter takes a connection you have already made. So a grid can drive a full analytical engine without this package carrying one, and installing Lattice never installs anything else.

An adapter declares what it can answer. No engine speaks the whole query. OData takes a condition tree but only some operators; a single-term API takes one field and one value; a hand-written endpoint may take nothing but a page number. The adapter states its capabilities, the SDK divides the request accordingly, and the grid finishes whatever is left.

CapabilityValuesMeaning
filterfalse | 'term' | 'flat' | 'tree'Nothing, a single field and term, a flat conjunction, or a full condition tree.
operatorsstring[]Which comparisons the engine understands. A condition using anything else stays with the grid.
sortfalse | 'single' | 'multi'How many columns it can order by.
quickbooleanWhether free-text search across columns can be pushed.
rangebooleanWhether it can return a window rather than the whole result.
totalbooleanWhether it can report how many rows match.

Anything left over means the whole result is fetched. Filtering a window of rows in the browser is not a slower way to get the right answer, it is a fast way to get a wrong one: the rows that belong on the first page may be on the ninth, and the count is whatever the engine happened to return. So when the grid has work left to do it asks the engine for the complete result, applies the remainder, and pages from what it holds. It says so once, naming the part that could not be pushed, because the fix is usually a wider adapter rather than a bigger machine. source.lastPlan() reports the division for any request.

A conjunction splits; a disjunction does not. An and group narrows with each condition, so the engine can apply the conditions it understands and the grid narrows what comes back. An or group widens with each branch, so pushing only the supported branches returns fewer rows than the filter allows, and the grid cannot recover rows that were never fetched. A disjunction the engine cannot fully answer therefore stays with the grid whole. The same asymmetry governs faceting.

A sort is pushed whole or not at all. Ordering by the first column and fixing the rest in the browser needs every row anyway, so a partial sort buys nothing and returns rows in an order that is wrong until the grid corrects it.

AdapterForNotes
odataAdapterAny OData v4 endpointWrites $filter, $orderby, $top, $skip and $count. System options keep their $ unencoded, which several servers require.
restAdapterThe API you already haveParameter names are yours to choose. Paging and sorting are assumed; filtering is assumed absent until you declare operators, because an adapter that claims to filter when the endpoint ignores it returns the wrong rows silently.
duckdbAdapterA DuckDB connectionWrites SQL and takes the whole query: filter tree, multi-column sort and paging. from is any FROM expression, so read_parquet('s3://bucket/*.parquet') is as valid as a table name. The engine is yours to create and install; this imports nothing, so the bundle is unchanged whether you use it or not.
dfqlAdapterDemandFlow entitiesSpeaks POST /v1/query. Sends the entity, the key attribute and the prefix to match, a field projection and one field-and-term filter, matched as a case-insensitive substring. It cannot sort or page, so the grid does both, and every request carries a countOnly line because limit caps rows scanned rather than matched: a filtered query returns an arbitrary subset, and the count is the only thing that reveals it.

Wiring it to the API you already have

Most data sits behind a service someone on your team wrote. The adapter below sends four parameters and expects { rows, total } back. Start by declaring only what the endpoint genuinely does, and widen it as you teach the endpoint more.

const source = createPushdownSource({
  compute,
  adapter: restAdapter({
    url: '/api/orders',
    // Only the comparisons the endpoint really applies. Claiming more here
    // returns the wrong rows rather than merely running slowly.
    operators: ['eq', 'gt', 'lt', 'contains'],
    params: { offset: 'skip', limit: 'take' },
  }),
});

The request that reaches your service, and the answer it owes:

ParameterExampleMeaning
skip / take40, 20The window. Return exactly that slice.
sort / orderamount,name / desc,ascColumns in priority order, and a direction for each.
filterJSON condition treeOnly the conditions your declared operators cover. Everything else the grid keeps.
qfree textPresent only when you declare quick: true.
// Express. FastAPI and ASP.NET differ only in how the query string is read.
app.get('/api/orders', async (req, res) => {
  const { skip = 0, take = 100, sort, order, filter } = req.query;

  let q = db('orders');
  if (filter) q = applyConditions(q, JSON.parse(filter));   // your translation
  if (sort) {
    sort.split(',').forEach((col, i) => {
      q = q.orderBy(col, (order || '').split(',')[i] === 'desc' ? 'desc' : 'asc');
    });
  }

  // The count is of everything matching, not of the page. A grid scrollbar is
  // sized from it, so a page-sized total makes the grid look empty below.
  const [{ count }] = await q.clone().clearOrder().count({ count: '*' });
  const rows = await q.offset(Number(skip)).limit(Number(take));

  res.json({ rows, total: Number(count) });
});

The total is the commonest mistake. It is the number of rows matching the filter, not the number returned in this page. The grid sizes its scrollbar from it and requests windows against it, so returning the page length makes a large result look like one page.

Building an adapter from the parts

createPushdownSource is the whole story for most callers. When an engine needs a source of its own, the four pieces it is assembled from are exported separately, so a custom source can plan and finish work the same way rather than reimplementing the split.

ExportSignatureDescription
capabilitiesOf(declared?) => Required<PushdownCapabilities>Resolves what an adapter declared against the defaults, giving a complete set with no absent keys to test for.
splitFilters(filters, caps) => { pushed, residual }Divides a condition tree into the half the engine takes and the half left over. A conjunction splits; a disjunction that is not fully supported stays whole on the client, because pushing part of an or returns fewer rows than the filter allows and the grid cannot recover what was never fetched.
planQuery(request, caps) => PushdownPlanPlans one request: the query to send, the work to finish afterwards, whether the whole result is needed, and which parts stayed behind.
applyResidual(rows, residual, compute) => unknown[]Applies whatever the engine could not, through the grid's own filter and sort kernels rather than a second implementation, so a residual predicate means exactly what the same predicate means anywhere else.
NO_CAPABILITIESReadonly<Required<PushdownCapabilities>>The set an adapter that declares nothing is treated as having: everything off. Such an adapter still works; the grid simply does all the work.

Residual work needs the complete result. applyResidual expects every matching row, not a window. Filtering a window 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. planQuery sets needsAll whenever that applies, and createPushdownSource switches to fetching everything and paging from what it holds.

Joining two grids

Two grids each holding their own data, and a third showing where they meet. Orders against customers; shipments against carriers; enrolments against students. The third grid derives from one side and names the other as its join partner.

const joined = createGrid(host, {
  source: {
    mode: 'derived',
    from: orders,
    join: {
      with: customers,
      on: { left: 'customerId', right: 'id' },
      select: ['name', 'tier'],
    },
  },
  columns: [{ field: 'ref' }, { field: 'name' }, { field: 'tier' }, { field: 'amount' }],
});
KeyTypeDescription
withGridRequired. The grid holding the other side.
onstring | { left, right }Required. The shared key: one field name when both sides use it, or one each.
type'inner' | 'left'inner by default, keeping only rows that matched, which is usually what “common data” means. left keeps every row and leaves the brought-across fields undefined, the shape you want when the unmatched rows are the finding.
selectstring[]Which of the partner's fields to bring across. All of them by default.
prefixstringRename the brought-across fields, for when both sides have a name worth keeping.
follow'all' | 'filtered'Which of the partner's rows to read. all by default: a lookup table is normally the whole table, and a customer list filtered to Europe would otherwise silently drop every other order from a grid the reader takes to be all orders.

The row count does not change. A key appearing twice on the right keeps the first match rather than emitting a row per pair. SQL would multiply them out; here that would change the row count of a grid the reader thinks of as “the orders” and quietly double every total taken from it.

Both sides are live. The partner is read at derivation time, not captured when the grid was built, and editing it re-derives, a corrected tier in the customer grid moves the order into a different band in the joined one.

Cross-filtering: the path back up

Derivation runs one way. A derived grid reads its source and never writes to it, which is what makes a chain of them safe to reason about. Cross-filtering is the single deliberate path back up: clicking a row in a summary panel filters the grid it summarises.

const byRep = createGrid(panel, {
  source: {
    mode: 'derived', from: main, groupBy: 'rep', refresh: 'live',
    crossFilter: true,
    select: { total: { of: 'amount', fn: 'sum' } },
  },
  columns: [{ field: 'rep' }, { field: 'total' }],
});

byRep.on('row:click', (e) => byRep.crossFilter.toggle(e.key));
MemberReturnsDescription
enabled()booleanWhether this grid can cross-filter a source. False on a grid that is not derived, or whose source has no crossFilter.
column()string | nullThe source column the filter is pushed onto.
get()string[]The keys currently filtering the source.
set(keys)voidFilter the source to these derived rows. null clears.
toggle(key)voidAdd or remove one key: what a click handler wants.
clear()voidTake this grid's filter off its source.

A panel does not filter itself. The grid pushing the filter leaves its own condition out when it reads the source back. Without that, clicking one rep would collapse the panel to that single row and strand the reader with nothing else to click. It is the same rule that keeps a header histogram showing every bar after you click one (facets), applied between grids instead of within one.

Several panels compose. Each leaves out only its own condition, so two panels over different columns narrow each other while both stay whole: pick a rep and the region panel shows that rep's regions, pick a region and the rep panel shows that region's reps.

It needs a memory source. Leaving a panel's own condition out means asking the source for every row that survives the other filters, which a source holding one page cannot answer. Over a remote, paged or stream source the read falls back to the ordinary filtered rows (narrowed by the very column the panel asked to be excluded from) and the panel collapses to the row that was clicked. It warns when it does. Exclude the originating panel on the server instead.

It is an ordinary filter. The condition goes through the source's filters.set, so it undoes, rides in a saved view, and appears in whatever filter UI the grid already has. There is no second filter model beside the real one.

The remote request

Your fetch receives one object and returns { rows, total }.

FieldTypeDescription
range{ start, end }The block wanted, end exclusive. Not from/to.
sort{ col, dir }[]In priority order.
filtersFilterSetThe condition tree, in the wire form described under operators.
quickstringPresent only when the quick filter is set.
groupBy / groupPathstring[] / unknown[]Which columns group, and which node this block belongs to.
pivotBy / pivotModestring[] / boolean
totalsstring[]Columns wanting an aggregate, so the server can compute them.
contextunknownYour own config.context, passed through untouched.
signalAbortSignalAborted when the request is superseded: pass it to fetch.
protocolnumberWire version, so a server can tell what it is talking to.

Blocks are requested as the viewport reaches them and cached. Changing the sort, the filter or the grouping invalidates the cache and re-queries.

grid.updates

Control over an incoming feed: hold it, let it through, and see what the batching is actually saving you. Pausing does not drop anything: held changes keep merging, so a long pause costs one entry per changed row rather than one per update.

grid.updates.pause();                // hold the feed; it keeps arriving and merging
grid.updates.stats();                // { pending, queued, coalesced, flushes, ... }
grid.updates.flush();                // apply what is waiting, stay paused
grid.updates.resume();               // apply everything and go live again
MethodReturnsDescription
pausedbooleanTrue while updates are held.
pause()booleanHold incoming updates. True when this call paused it.
resume()objectApply everything held and start applying again. Returns the rows added, updated and removed.
flush()objectApply what is waiting without leaving the paused state, a single step.
stats()objectCounters for the feed and the buffer: what arrived, what will be applied, and the difference.
log(opts?)object[]The timestamped changes still held, oldest first. since narrows to a time window.

coalesced is the number a batching strategy is actually bought with: rows that arrived more than once in a window and were written once. A feed where it stays at zero is not being coalesced, whatever the interval says.

The log is bounded two ways, because an entry is not a fixed size, one carrying a single changed cell and one carrying a fifty-thousand-row batch both count as one.

OptionDefaultDescription
updates.logLimit2000How many changes are kept.
updates.logRows100000How many rows those changes account for between them. A feed delivering large batches reaches this one first.
updates.flush'frame'frame lands on a paint boundary, which is what makes one repaint per batch reliable. microtask at the end of the current task, interval on the coalescing window, manual only when you call flush().
updates.maxQueued20000Queued rows that force an early flush, whatever the strategy: including manual.
updates.budgetMs10Milliseconds one flush may spend applying before deferring the rest to the next frame.

Applying changes

rows.apply(change) applies immediately and returns what happened; rows.queue(change) batches into the next flush and returns a promise. Both take the same shape: add, update, remove, and an optional at insert position.

An update is a patch, not a replacement. Fields absent from the update are untouched, so a delta from a websocket or a save response can be applied as it arrives without reading the row back first. Coalescing merges fields too: {price} and {volume} arriving as separate messages inside one window both survive.

grid.rows.apply({ update: [{ id: 'R1', price: 42 }] });
// every other field on R1 is left alone

Rows that cannot be applied are reported, not thrown. A batch of a thousand containing three bad rows applies the other 997 and lists the three.

ReasonMeaning
unknown-idAn update or remove naming a row that is not in the grid.
duplicate-idAn add whose key already exists. Refused rather than admitted: selection, expansion, comments and the key index all resolve one key to one row.
const result = grid.rows.apply({ update: [ ... ] });
result.rejected;  // [{ operation, id, reason }]

These are batches, not database transactions. There is no isolation and no all-or-nothing guarantee: partial application with per-row rejection is the defined behaviour, which is why the API is not called a transaction.

stats() reports held against heldLimit: what the log is carrying now, against what it will carry. rows is a lifetime total of everything that ever arrived and says nothing about memory; these two do. Raise logRows for a deeper scrubber on a grid you have measured, and lower it on a feed of very wide rows.