demo D30
Computed columns in htmx
A column derived from others, recomputed only when its inputs move
value: { deps, compute }
This grid derives a column from other columns on the same row and recomputes it only when one of its declared inputs changes. It keeps calculated fields correct and cheap, so a total or a margin updates the moment its inputs move and stays idle otherwise.
This is the htmx version. A server-rendered table is upgraded in place, so the page is readable before any script runs and gains the full grid when the module loads, which suits a progressively enhanced app.
The configuration
<link rel="stylesheet" href="https://cdn.latticegrid.dev/lattice-grid@1.107.0/lattice-grid.min.css">
<!-- A real, server-rendered table: readable with JavaScript off. data-field on
each header names the column that cell becomes, and data-id on each row is
the key the grid carries it under. -->
<table id="computed-columns-table" data-lattice-grid>
<thead>
<tr>
<th data-field="pipeline">Pipeline</th>
<th data-field="stage">Stage</th>
<th data-field="elapsed">Elapsed</th>
<th data-field="records">Records</th>
<th data-field="bytesOut">Bytes out</th>
</tr>
</thead>
<tbody>
<tr data-id="1"><td>ingest</td><td>validate</td><td>2611343</td><td>3360749</td><td>6544065057</td></tr>
<tr data-id="2"><td>archive</td><td>publish</td><td>889055</td><td>249878</td><td>481754601</td></tr>
<tr data-id="3"><td>ingest</td><td>validate</td><td>11569</td><td>1880377</td><td>886469447</td></tr>
<!-- the rest of the rows, rendered by your server -->
</tbody>
</table>
<script type="module">
import { hydrateTable } from 'https://cdn.latticegrid.dev/lattice-grid@1.107.0/modules/htmx.esm.min.js';
// The markup carries the rows; a server-rendered table's column types are
// declared in the config hydrateTable takes. The grid replaces the table
// where it stood.
const grid = hydrateTable(document.getElementById('computed-columns-table'), {
toolPanel: {
side: 'left',
panels: ['columns', 'filters', 'views', 'quick'],
actions: ['undo', 'redo', 'export', 'restore', 'maximise'],
exportName: 'lattice-demo',
},
columns: [
{ field: 'pipeline', title: 'Pipeline', filter: { type: 'set' }, layout: { width: 120, pin: 'start' } },
{ field: 'stage', title: 'Stage', filter: { type: 'set' }, layout: { width: 120 } },
{ field: 'elapsed', title: 'Elapsed', type: 'duration', layout: { width: 130 }, total: 'sum' },
{ field: 'records', title: 'Records', type: 'number', layout: { width: 130 }, format: { notation: 'compact' }, total: 'sum' },
{ field: 'bytesOut', title: 'Bytes out', type: 'bytes', layout: { width: 130 }, total: 'sum' },
{
id: 'rps', title: 'Records / sec', type: 'number', layout: { width: 150 },
format: { notation: 'compact' }, total: 'avg', filter: { type: 'number' },
// deps names the fields this value reads, so the grid knows which
// columns invalidate it and can cache it rather than recompute on every
// scroll. A dep read but not declared is warned about, loudly.
value: {
deps: ['records', 'elapsed'], pure: true,
compute: (d) => (d.elapsed ? (d.records * 1000) / d.elapsed : null),
},
},
{
id: 'throughput', title: 'Throughput', type: 'bytes', layout: { width: 140 },
value: {
deps: ['bytesOut', 'elapsed'], pure: true,
compute: (d) => (d.elapsed ? (d.bytesOut * 1000) / d.elapsed : null),
},
},
{
id: 'perRecord', title: 'Bytes / record', type: 'number', layout: { width: 150 },
format: { decimals: 0 },
value: {
deps: ['bytesOut', 'records'], pure: true,
compute: (d) => (d.records ? d.bytesOut / d.records : null),
},
},
{
id: 'grade', title: 'Grade', layout: { width: 120 }, filter: { type: 'set' },
// A computed column can depend on another computed column. The graph
// orders them; nothing here says which runs first.
value: {
deps: ['records', 'elapsed'], pure: true,
compute: (d) => {
const rps = d.elapsed ? (d.records * 1000) / d.elapsed : 0;
if (rps > 200_000) return 'fast';
if (rps > 20_000) return 'normal';
return 'slow';
},
},
cell: { decoration: 'pill', variant: { map: { fast: 'success', normal: 'info', slow: 'warning' } } },
},
{
id: 'label', title: 'Summary', layout: { flex: 1, min: 220 },
// deps: '*' hands the whole record over. Convenient, and the reason to
// prefer a named list: every column change invalidates it.
value: {
deps: '*', pure: true,
compute: (d) => `${d.pipeline}/${d.stage} ${d.outcome} in ${Math.round(d.elapsed / 1000)}s`,
},
},
],
state: { sort: [{ col: 'rps', dir: 'desc' }] },
});
</script>
Deriving a column from other columns
A computed column holds no value of its own; it is worked out from other columns on the same row, and Lattice Grid recomputes it only when one of its declared inputs changes. The config option is value: { deps, compute }, where deps names the source columns and compute turns their values into the cell’s output: a margin from a cost and a price column, a full name from a first and last. A developer reaches for this instead of pre-joining the data upstream when the derivation is awkward to maintain as a stored field, particularly when the inputs are themselves edited in the grid and the derived column has to stay in step.
Because deps is explicit, Lattice Grid never guesses which edits matter: a change to a column outside the list leaves the computed cell untouched, and a change to a listed column invalidates only the rows it belongs to. In a JavaScript data grid holding a hundred thousand rows, that is the difference between a redraw confined to a handful of cells and a pass over the whole table. The computed cell still sorts, filters and exports through the same paths as a stored value, so a screen reader announcing a row reads the derived figure exactly as one read straight from the data.
How do you add a calculated column to a JavaScript data grid?
Declare the column with a value object instead of a static field: value: { deps: ['cost', 'price'], compute: (row) => row.price - row.cost }. Lattice Grid re-evaluates compute only for rows where a column listed in deps has changed, so the derived value stays correct without recomputing the whole column on every update.