developer guide
Lattice Grid developer guide
Install the grid, build your first one, then read the guide by topic. Start here, and use the API reference to look things up.
Download Lattice Grid The built files and the docs, from the public repository.
Version 1.13.0 Zero dependencies No build step
What it is
Lattice Grid renders tabular data in a browser. That sentence covers a great many products, so here is what is actually different about this one.
It holds data in columns, not rows
A grid of a hundred thousand rows and thirty columns is three million values. Held as row
objects, that is three million property lookups per pass and a great deal of memory the
garbage collector has opinions about. Lattice stores each column as one typed array: numbers
in a Float64Array, repeated strings as integers into a dictionary, booleans as
bits in a bitset.
Why it matters to you: sorting a column is a sort over one contiguous array of numbers, not a walk over a hundred thousand objects. Filtering produces a bitmask rather than a new array of rows. Both stay fast at sizes where the row-object approach has already given up, and neither allocates much, so the browser is not collecting garbage while the user is scrolling. How it works goes through the layout in full, including what it costs and where it does not help.
Work is memoised in stages
Everything between your data and the screen is six stages: filter, sort, group, total, pivot, flatten. Each remembers its result and the inputs it was computed from.
Change a sort and the filter stage is not recomputed: its inputs did not change. Edit a cell in a column nobody sorts, filters or groups on and none of the first five run; only the totals move. This is why a grid that is heavily filtered and grouped still feels immediate when you type in a cell.
The renderer reuses everything
Rows and cells come from pools. Scrolling reassigns the twenty or so row elements that exist
rather than creating and destroying thousands, and the only vertical write is a
transform, which the compositor handles without a layout pass.
No dependencies, and no build step
Not "few dependencies": none, at runtime and at build time. No lodash, no date library, no virtualisation library, no icon font. The bundler and minifier that produce the distribution are part of the repository. You can drop two files into a page and be finished.
A large share of enterprise line-of-business frontends are built without a bundler, and they are usually treated as second-class by grid vendors. Here the script tag is the first example in the documentation, not an appendix.
Install
Two files. Nothing is fetched at runtime (no CDN, no font, no sprite sheet) however the two files themselves got onto the page. Four equally valid ways to get them there:
npm
npm install @toclocoinc/lattice-grid
import { createGrid } from '@toclocoinc/lattice-grid';
import '@toclocoinc/lattice-grid/css';
const grid = createGrid(document.getElementById('grid'), config);
jsDelivr, no npm install, no bundler
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.7.1/lattice-grid.min.css">
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.7.1/lattice-grid.min.js"></script>
<script>
const grid = LatticeGrid.createGrid(document.getElementById('grid'), config);
</script>
Script tag, your own build
<link rel="stylesheet" href="lattice-grid.min.css">
<script src="lattice-grid.min.js"></script>
<script>
const grid = LatticeGrid.createGrid(document.getElementById('grid'), config);
</script>
ES modules, your own build
import { createGrid } from './lattice-grid.esm.min.js';
const grid = createGrid(document.getElementById('grid'), config);
jsDelivr mirrors every version published to npm at
cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@<version>/<file>: pin an
exact version, e.g. @1.7.1 rather than @latest, so a later release
does not change what a page already in production loads. The same convention reaches a
module: .../modules/htmx.esm.min.js, .../modules/dhtmlx-compat.esm.min.js,
and so on. Type declarations resolve automatically through npm's own types field;
for editor tooling against the CDN or a plain script tag, point your tsconfig at
lattice-grid.d.ts directly.
Everything else in the distribution is an alternative packaging or a development aid:
| File | What it is |
|---|---|
| lattice-grid.min.js | The whole product as a UMD build. Defines window.LatticeGrid, and works with AMD and CommonJS loaders. |
| lattice-grid.min.css | The single stylesheet. Without it the grid is in the DOM and unreadable, no widths, no scrolling, no theme. |
| lattice-grid.esm.min.js | The same, as an ES module. |
| lattice-grid.d.ts | Type declarations. |
Your first grid
Three things are required: an element to mount into, some columns, and some rows. Everything else has a working default.
The whole thing
const grid = LatticeGrid.createGrid(document.getElementById('grid'), {
rowKey: 'id',
columns: [
{ field: 'circuitId', title: 'Circuit' },
{ field: 'region' },
{ field: 'monthlyCharge', type: 'number', format: 'currency:GBP:2', total: 'sum' },
{ field: 'installedOn', type: 'date', format: 'date:dd MMM yyyy' },
],
rows: data,
});
A few things happened there without being asked for. region got a title of
"Region", a field name is turned into a readable heading rather than left as-is. The number
column right-aligned itself, because numbers align right and a grid should not need telling.
The date column parsed 2024-03-11 and rendered 11 Mar 2024. And
total: 'sum' put a figure in the totals row.
On rowKey: it names the field that identifies a row. Set it
if you have one. Without it the grid assigns keys per row object, which is enough for
sorting, filtering, selection and copying within a session, but not across a reload, because
new objects are new rows. Change tracking, streaming dedupe, selection persistence and remote
reload all want a real key, and the grid warns once, naming them.
React, Vue, Svelte
One optional bundle per framework. They are thin: the grid is created once against a host element, prop changes are pushed into it through the same public API you would call by hand, and it is destroyed on unmount.
You pass the framework in. Every adapter is a factory taking the
framework and createGrid, rather than importing either. Lattice ships zero
dependencies and the bundler rejects bare specifiers outright, so an adapter could not
import React from 'react' even if it wanted to. The same choice keeps each
bundle small: the bundler inlines whatever it can resolve, so an adapter that imported the
grid would carry a second copy of it. Passing both in leaves each adapter a
few kilobytes of glue, and means the adapter cannot disagree with the grid version you
already loaded.
React
import React from 'react';
import { createGrid } from '@toclocoinc/lattice-grid';
import { createLatticeGrid } from '@toclocoinc/lattice-grid/modules/react';
const LatticeGrid = createLatticeGrid({ React, createGrid });
function Circuits({ rows }) {
const ref = React.useRef(null);
return (
<LatticeGrid
ref={ref}
className="grid"
columns={columns}
rows={rows}
rowKey="id"
sort={[{ col: 'name', dir: 'asc' }]}
onCellChanged={(e) => save(e.key, e.colId, e.value)}
/>
);
}
// ref.current.grid is the live grid, for anything without a prop.
Hold your props steady. Change detection is reference equality, because
deep-comparing a million-row array on every render would cost more than the reload it
avoids. Build columns once outside the component, or memoise it; a fresh array
literal on each render tells the grid the columns changed and it will rebuild them. Rows are
the same: hand back a new array when the data actually changes, not before.
StrictMode is handled. React 18 deliberately mounts, unmounts and mounts again in development; the effect cleanup destroys the first grid, so the second starts clean and nothing leaks.
Vue 3
import * as vue from 'vue';
import { createGrid } from '@toclocoinc/lattice-grid';
import { createLatticeGrid } from '@toclocoinc/lattice-grid/modules/vue';
const LatticeGrid = createLatticeGrid({ vue, createGrid });
<LatticeGrid
:columns="columns"
:rows="rows"
row-key="id"
:sort="[{ col: 'name', dir: 'asc' }]"
@cell-changed="onCellChanged"
@selection-changed="onSelectionChanged"
/>
Events are re-emitted under dashed names: cell:changed becomes
@cell-changed, because a colon in a Vue template is directive syntax and
cannot be bound. Every event in the table below is declared in emits.
Svelte
<script>
import { createGrid } from '@toclocoinc/lattice-grid';
import { createLatticeAction } from '@toclocoinc/lattice-grid/modules/svelte';
const lattice = createLatticeAction({ createGrid });
let rows = [];
</script>
<div
use:lattice={{ columns, rows, rowKey: 'id' }}
on:cell-changed={(e) => save(e.detail)}
></div>
An action, not a component. Svelte's action contract is
{ update, destroy } over a node the caller already owns, which is exactly the
shape of the work: create, push changes, tear down. A component wrapper would add an element
and a props layer to arrive back at the same three calls. It is also the only adapter here
that needs nothing but createGrid, since an action is a plain function with no
framework runtime behind it. Grid events arrive as CustomEvents on the node,
with the grid event as detail. If you want a component, four lines around this
gets you one.
What the adapters do not do. They add no features and wrap no API: the
grid instance is the same object the vanilla examples use, and anything without a prop is
reached through it directly (via the ref in React, expose in Vue, or a
reference you keep in Svelte). Nothing is proxied, so nothing can lag behind the grid.
The web component is self-contained: use it or the API, not both
The custom element carries the grid inside it, rather than being handed one. That is the point of the format: you add the element and it works, with nothing to wire up.
Do not load it alongside createGrid in the same page. You would
get two independent copies of the grid, and the cost is not the download, it is that
each copy keeps its own registries. A renderer, editor, data type or variant registered
through one is invisible to the other, and a licence key validated in one is not validated in
the other. Nothing errors; the custom renderer you registered simply never appears.
Pick one route per application. The other three adapters take createGrid as an
argument and so share whatever copy you already loaded, and can be mixed with direct API use
freely.
How it works
Four ideas explain most of the API. If you read nothing else, read this section, the rest of the guide assumes it.
The row you see is not the row you supplied
Your data objects are held by reference and never copied. What the grid hands back is a
row wrapper: your object under data, plus the identity and position the
grid needs: key, index, level, whether it is a group
row, whether it is expanded.
Reading a value
grid.rows.get(0).data // your object, untouched
grid.rows.value('r1', 'cap') // the raw value
grid.rows.text('r1', 'cap') // the formatted text, as rendered
grid.rows.values('r1') // every readable column, as an object
value and text are different questions and the difference bites
people. A currency column's value is 1234.5; its text is
£1,234.50. Sorting and filtering use the value. Copying, exporting and searching
use the text, because that is what the user can see and what they typed against.
Display index and row key are different things
A display index is a position: row 0 is whatever is at the top right now, and it changes when you sort. A key identifies a record for as long as it exists. Anything that has to survive a sort, a filter or a page change is keyed.
Position: fragile
grid.rows.get(4)
grid.selection.setRange({
startRow: 0, endRow: 9, columns: ['cap'],
})
Identity: durable
grid.rows.byKey('CIR-100042')
grid.edit.setCells([
{ key: 'CIR-100042', colId: 'cap', value: 99 },
])
Everything is on one event bus
There are no onSomething configuration properties. One bus, one
grid.on(type, handler), and every payload carries type,
origin and grid alongside its own fields.
Subscribing
const off = grid.on('cell:changed', e => save(e.row.data));
grid.once('ready', init);
grid.on('*', e => console.log(e.type, e)); // wildcard, for working out what fires
off(); // every subscription returns its own unsubscribe
origin tells you where a change came from: 'user',
'api', 'init', 'undo'. It is what stops a feedback
loop when you persist changes: a handler that writes to a server on
cell:changed should usually ignore its own 'undo' traffic, or at
least know that is what it is looking at.
Two rules hold across the whole API
Nothing is a double negative. There is no suppressX, no
disableY. Options are positive and say what they enable:
edit, sortable, allowGroup. Turning something off is
false.
Every configuration key is settable at runtime through
grid.set(key, value) and readable through grid.get(key). There is no
separate "you can only pass this at construction" list to memorise.
Which version am I running?
grid.getVersion(); // '1.7.1'
LatticeGrid.getVersion(); // the same, when you have no grid to hand
On the instance as well as the module, because that is where it is wanted: a bug report says "the grid on this page", and whoever reads it has a grid rather than the module it was built from.
The guide, by topic
Each topic is its own page, so you can link to the one that answers the question in front of you.
-
Columns, types and cell rendering
Defining columns, the built-in types and formatting, the cell renderers and charts, and the formatting rules that colour a cell by its value.
-
Sorting, filtering and grouping
Single and multi-column sort, the filter menu and condition tree, quick filter, header histograms, and grouping with totals and pivot.
-
Editing
Inline and row editing, validation, optimistic writes with rollback, cell ranges, the fill handle, and formulas typed into a cell.
-
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.
-
Statistics, capability and intervals
What the grid knows about its own numbers: column profiling, process control and capability, confidence intervals, and the tiles that put a figure above the table.
-
Tree data and master-detail
A hierarchy from a parent reference or a path, orphans and cycles absorbed rather than dropped, lazy children, and rows that expand into a nested grid.
-
Collaboration, comments and permissions
Live presence and cursors, threaded cell comments, column permissions against a user context, audit and diff mode, and redacting a column for a screen share.
-
Accessibility and keyboard
Built to WCAG 2.2 level AA: the full keyboard map, roving focus over a virtualised body, the ARIA the grid writes, and forced-colours support.
-
Theming and density
Styling with CSS custom properties rather than overriding rules, the four built-in themes, and the density presets that follow one token.
-
Presenting, saved views and history
Presentation mode for a room, full-screen maximise, the time scrubber, saved views, undo and redo across the whole grid, and the left tool rail.
-
Export, clipboard and the web component
Excel and CSV export written with no dependency, clipboard interop with Excel and Sheets, the <lattice-grid> web component, and the full event API.
-
Using Lattice Grid with htmx
Hydrating a server-rendered table into a grid, surviving htmx swaps, and driving sort, filter and infinite scroll as htmx requests the server answers.
-
Coming from dhtmlx Grid
A compatibility wrapper shaped like dhtmlx's dhx.Grid, so the constructor and the data, selection, history, export and event namespaces keep working after an import swap.
-
Licensing, the model layer and recipes
How the licence key works, driving the grid from a language model through the intent layer, and worked recipes for the shapes that come up most.
Frameworks and modules
Using the grid from a framework, and the two optional modules. The adapters are factories you hand your framework and createGrid to, so they add no dependency and no second copy of the grid.
-
React
Install the adapter, the factory pattern, props through the public API, cleanup and TypeScript.
-
Vue
The Vue 3 adapter as a factory, reactive props, events, and cleanup on unmount.
-
Svelte
The Svelte adapter as a use: action, so no framework runtime is passed in.
-
Web component
A <lattice-grid> custom element for a page with no bundler, and the one-or-the-other rule.
-
Charts
The charts module, createChart, the thirty-five types, following the grid and click-to-filter.
-
Derived and chained grids
A grid built from another and following its filter: grouping, unnesting, profiles, chaining to any depth, and tiles that read the same rows.
-
Pushdown sources
Push the grid's query down to DuckDB, an OData service or your own API, take back only what it asked for, finish the rest locally, and see the split.
-
Statistics and shadow columns
grid.statistics, the panel, shadow columns and process capability.
-
Data types and units
The eighty-eight technical types, unit configuration, and registering a family of your own.