Lattice Grid Buy a licence

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:

FileWhat it is
lattice-grid.min.jsThe whole product as a UMD build. Defines window.LatticeGrid, and works with AMD and CommonJS loaders.
lattice-grid.min.cssThe single stylesheet. Without it the grid is in the DOM and unreadable, no widths, no scrolling, no theme.
lattice-grid.esm.min.jsThe same, as an ES module.
lattice-grid.d.tsType 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.