Lattice Grid Buy a licence

developer guide

The htmx integration: JavaScript Data Grid Guide

Hydrating a server-rendered table into a grid, surviving htmx swaps, and driving sort, filter and infinite scroll as htmx requests the server answers.

Developer guide › The htmx integration

Using with htmx

lattice-grid/modules/htmx lets a grid survive htmx's own DOM swaps, hydrate from a server-rendered <table>, and drive sort, filter and infinite scroll over plain htmx requests, the server owns pagination and the request lifecycle; this module only wires the grid's own state to it. Importing it is enough for the lifecycle half: it registers itself against document on load.

Declarative init

<script src="https://unpkg.com/htmx.org@2"></script>
<script type="module" src="dist/modules/htmx.esm.min.js"></script>

<div id="grid" data-lattice-grid></div>
<script type="application/json" data-lattice-config>
  { "columns": [{ "field": "name" }, { "field": "qty", "type": "number" }] }
</script>

// Anywhere on the page, once:
import { autoInit } from '@toclocoinc/lattice-grid/modules/htmx';
autoInit(document);

One file, not two. autoInit comes from modules/htmx itself here, not from the base package: this module already carries a complete, independently-bundled copy of createGrid and everything it depends on, the same way every module built this way does (a bundle inlines what it imports; it has no way to reach across to a copy some other <script> tag happens to have loaded). Left unexported, a page using htmx integration would load that engine twice: once for modules/htmx, again for the base package's own createGrid. This module re-exports createGrid, autoInit, hydrateTable, readTable, serialiseState and restoreState precisely so a page never has to choose between the two, it is the complete package for anything touching htmx, not an add-on alongside the base one.

Surviving a swap. autoInit(root) builds a grid on every [data-lattice-grid] element under root it has not already built one for: idempotent, so calling it again after a swap only picks up what is new. A sibling <script type="application/json" data-lattice-config> supplies columns and options; without one, a <table> element is hydrated instead, reading its header row for columns and its body rows for data, then replacing itself with the grid. The element that replaces a hydrated table carries the table's id, so a viewer bound by that id - a calendar, Kanban board, map, chart or KPI tile - finds the grid, including after a swap replaces the table. Once imported, this module listens for htmx's own htmx:beforeCleanupElement and htmx:load and calls grid.destroy() / autoInit at the right moments automatically, a grid inside a swapped-out subtree is torn down before htmx detaches it; a grid inside newly-loaded content is built without re-scanning the whole page. A page with JavaScript disabled sees the plain <table>, still readable, since it is only ever replaced once the grid has actually mounted. A committed cell edit on a declarative grid is re-raised as a bubbling lattice:grid-change on the host with { key, field, oldValue, newValue }, so an hx-trigger can POST it; a programmatic rows.apply/rows.load announces rows:changed on the grid's own bus and re-raises nothing.

Finding a live grid from its element works everywhere in this library, not just through autoInit: element.__lattice holds the instance for any element createGrid was called on, and is cleared when the grid is destroyed.

The calendar viewer initialises through data-lattice-calendar the same declarative way,: autoInitCalendar builds a calendar on every [data-lattice-calendar] element it has not already built one for (idempotent, like autoInit), reading its configuration from a sibling <script type="application/json" data-lattice-config> or a data-lattice-config attribute. A declared calendar binds a grid by id - a data-lattice-bind="<grid id>" attribute, or a grid: "<grid id>" string in the config - resolved through element.__lattice; without a grid, it hydrates straight from a server-rendered <table> inside the element (each <tr> one event, the <th data-field> cells the fields), replacing the table. The calendar engine is inlined into the htmx bundle, so importing that one module builds the calendar; registerCalendar(createCalendar) is an optional override for an adapter that wants its own factory. CALENDAR_ATTR names the marker (data-lattice-calendar). Every move or resize is re-raised as a bubbling lattice:calendar-change on the host with { id, start, end } - the event id and the new start/end as ISO strings - so an hx-trigger can POST the reschedule. An out-of-band data-lattice-row="<event id>" fragment patches that event through the calendar's own rows.apply (wired by driveCalendarOobUpdates), and browser history carries the view the same way a grid's does: on htmx:beforeHistorySave the original table is swapped back in for the snapshot, restoreCalendarStateWithin re-reads it and reapplies the view, date, first day and time-axis scroll on a cache hit, and restoreSwappedCalendars puts the rendered view back once htmx has snapshotted.

The Gantt initialises through data-lattice-gantt over its server-rendered task table. autoInitGantt builds the split view on every [data-lattice-gantt] element it has not already built one for (idempotent, like autoInit and autoInitCalendar): the element wraps a plain <table> whose <th data-field> cells name the left-hand columns and whose <tr>/<td> cells are the tasks, so without JavaScript it still reads as a table, and one htmx:load turns it into the split view. Because the Gantt engine is not inlined into the htmx bundle, the page hands the adapter its factory first - registerGantt(createGantt) from the page's own Gantt module, or the script-tag global globalThis.LatticeGridGantt.createGantt the module's UMD build publishes - so a page with htmx and no Gantt pays nothing for it. Every committed change (move, resize, progress, cell edit, link, add, delete, row move) is re-raised as a bubbling lattice:gantt-change on the host with { kind, id, task, changes }, and an out-of-band data-lattice-row="<task id>" fragment patches that task through the Gantt's own rows.apply. Browser history carries the view the same way a grid's does: on htmx:beforeHistorySave the original table is swapped back in for the snapshot, restoreGanttStateWithin re-reads it and reapplies zoom, visible range, scroll, collapsed rows and column widths on a cache hit, and restoreSwappedGantts puts the rendered plan back once htmx has snapshotted.

The resource scheduler initialises through data-lattice-scheduler over its server-rendered event table. autoInitScheduler builds a scheduler on every [data-lattice-scheduler] element it has not already built one for (idempotent, like autoInit): the element wraps a plain <table> whose <th data-field> cells name the event fields (resourceId, startDate, endDate, name) and whose <tr>/<td> cells are the events, and the scheduler derives its resource rows from the events' resource field. Because the scheduler engine is not inlined into the htmx bundle, the page hands the adapter its factory first - registerScheduler(createScheduler) from the page's own scheduler module, or the script-tag global globalThis.LatticeGridScheduler.createScheduler the module's UMD build publishes - so a page with htmx and no scheduler pays nothing for it. Every reassign is re-raised as a bubbling lattice:scheduler-change on the host with { id, kind, resource: { from, to } }, and an out-of-band data-lattice-row="<event id>" fragment patches that event through the scheduler's own events.apply. Browser history carries the view the same way a grid's does: on htmx:beforeHistorySave the original table is swapped back in for the snapshot, restoreSchedulerStateWithin re-reads it and reapplies the zoom and scroll on a cache hit, and restoreSwappedSchedulers puts the rendered schedule back once htmx has snapshotted.

A server form enhances in place through data-lattice-form, and still submits with JavaScript off. autoInitForm turns every [data-lattice-form] element it has not already enhanced (idempotent, like autoInit) into a Lattice form without replacing the markup: each named field keeps its name, value, label and validation, and the form's own submit controls survive the mount, so an hx-post posts the form's live values while the plain <form> is the JavaScript-off fallback. Because the form engine is not inlined into the htmx bundle, the page hands the adapter its enhancer first - registerForm(enhanceForm) from the page's own form module, or the script-tag global globalThis.LatticeGridForm.enhanceForm the module's UMD build publishes - so a page with htmx and no form pays nothing for it. The submit path validates before it posts: an invalid form cancels the submit (and the hx-post) and reports the failures through the validated event. FORM_ATTR names the marker (data-lattice-form), and browser history carries the form the way a grid's does: saveFormStateWithin writes the values to a data-lattice-form-state attribute before the snapshot, restoreFormStateWithin re-enhances any host that lost its instance and reapplies the saved values on a cache hit, and destroyFormWithin tears down only the forms in a subtree htmx is about to detach, so a sibling swap keeps the values a form elsewhere on the page already has.

The Designer initialises through data-lattice-designer, declared entirely from server-rendered state. autoInitDesigner builds a dashboard designer on every [data-lattice-designer] element it has not already built one for (idempotent, like autoInit): the config - a sibling <script type="application/json" data-lattice-config> or a data-lattice-config attribute - carries the mode, the saved state and the guardrails, plus any inline sources. Live data is bound from a grid by id - a data-lattice-bind="<grid id>" attribute, or a grid: "<grid id>" string in the config (with source: "<id>" renaming the source entry, which defaults to the grid id) - resolved through element.__lattice. Because the Designer engine is not inlined into the htmx bundle, the page hands the adapter its factory first - registerDesigner(createDesigner) from the page's own Designer module, or the script-tag global globalThis.LatticeGridDesigner.createDesigner the module's UMD build publishes - so a page with htmx and no Designer pays nothing for it. Every author change (add, remove, move, resize, page switch, undo, redo, panel chrome) is re-raised as a bubbling lattice:designer-change on the host with { state, cause }, the state including the selected page. Browser history carries it the way a grid's does: on htmx:beforeHistorySave the state is written to data-lattice-designer-state and the rendered designer swapped out for the snapshot, restoreDesignerStateWithin rebuilds it and reapplies the state - selected page included - on a cache hit, and restoreSwappedDesigners puts the rendered designer back once htmx has snapshotted.

The Kanban board initialises through data-lattice-kanban over its config. autoInitKanban builds a board on every [data-lattice-kanban] element it has not already built one for (idempotent, like autoInit, autoInitCalendar and autoInitGantt): the element's configuration - rows, columns, columnProperty, card, sprintProperty and the rest - comes from a sibling <script type="application/json" data-lattice-config> or a data-lattice-config attribute, and a declared board binds a grid by id - a data-lattice-bind="<grid id>" attribute, or a grid: "<grid id>" string in the config - resolved through element.__lattice; without a grid, it hydrates straight from a server-rendered <table> inside the element (each <tr> one card, the <th data-field> cells the fields), replacing the table. Because the board engine is not inlined into the htmx bundle, the page hands the adapter its factory first - registerKanban(createKanban) from the page's own Kanban module, or the script-tag global globalThis.LatticeGridKanban.createKanban the module's UMD build publishes - so a page with htmx and no board pays nothing for it. KANBAN_ATTR names the marker (data-lattice-kanban), an out-of-band data-lattice-row="<card id>" fragment patches that card through the board's own rows.apply (wired by driveKanbanOobUpdates), and browser history carries the board's view the same way a grid's does: saveKanbanStateWithin writes the view state to a data-lattice-kanban-state attribute before the snapshot, restoreKanbanStateWithin re-reads the config, rebuilds the board and reapplies the collapsed columns and lanes, the column order, the quick filter and the sprint/epic selection on a cache hit, and restoreSwappedKanbans puts the rendered board back once htmx has snapshotted.

A map initialises through data-lattice-map, bound to its grid. autoInitMap builds the configured map viewer on every [data-lattice-map] element it has not already built one for (idempotent, like autoInit): the element reads its config from the same data-lattice-config script or attribute a grid reads, and names its grid with data-lattice-bind="<grid id>" or a config.grid string, resolved through element.__lattice. The config's type selects the viewer: mapview (the default) builds the 3D Map View, leaflet/deck the matching adapter, anything else a chart type (markermap, geomap, …), with the remaining config keys passed through as that viewer's options. Because no map engine is inlined into the htmx bundle, the page hands the adapter its factories first - registerMapFactories({ mapview, chart, leaflet, deck }) from the page's own modules, or the script-tag globals globalThis.LatticeGridMapView.createMapView / globalThis.LatticeGrid.createChart / globalThis.LatticeGridLeaflet.bindLeaflet / globalThis.LatticeGridDeck.bindDeck - so a page with htmx and no map pays nothing for it. The built viewer is published on its container as __latticeMap, so a swap that replaces the grid re-binds a map left on the page through rebindViewersWithin, and a swap that removes the map releases its tile layers, observers and listeners. Browser history carries the view: on htmx:beforeHistorySave the centre, zoom and selected layer are written to a data-lattice-map-state attribute, and restoreMapStateWithin reapplies them on a cache hit.

Viewers re-bind across a swap. A chart or KPI panel bound to a grid inside a swapped-out subtree would otherwise keep pointing at the grid htmx:beforeCleanupElement just destroyed, and throw on its next draw. Instead the grid tells every viewer bound to it, and the viewer stops drawing - a chart or panel left unbound draws nothing and warns once by name (chart:grid-destroyed / kpi:grid-destroyed) rather than throwing. To re-bind it to the grid that lands in the fragment, mark the viewer's container with data-lattice-bind="<grid id>", where the value is the id attribute of the grid's host element, and give that element the matching id. On htmx:load the module builds the new grid, then walks the page for every [data-lattice-bind] container, resolves the named grid to the live instance now carrying that id, and calls viewer.rebind(grid) - a chart, panel, calendar or designer created by the page's own code publishes itself on its container (container.__latticeChart / container.__latticeKpi / container.__latticeCalendar / container.__latticeDesigner), which is how the two bundles find each other across the DOM. rebind(grid) is also a public method on all four, for a host that swaps grids itself - for the designer it writes the new grid's rows through designer.sources.update, so the designer's selection and undo history survive the swap.

Column type precedence when hydrating a table. A <th data-type="..."> sets that column's type to any built-in type name (the seven base types plus the extended catalogue); an unknown name is warned about once, by name, and ignored, leaving the column to sampled shape typing exactly as if no data-type had been given. The declaration also drives each cell's value: it wins over the value sniffer, so a text column keeps its leading zeros - '01' stays '01', not the number 1 - and a number/date/boolean column coerces to that type; a column with no declaration still runs the sniffer, reading numeric text as numbers. A sibling data-format likewise sets format when present. hydrateTable's own config.columns, when given, still takes precedence over both for the same column.

Making a hydrated column editable from the table. A <th data-edit> enables editing for that column: the bare attribute or data-edit="true" is the edit: true shorthand, data-edit="<editor>" names the editor exactly as the config shorthand does, and data-edit="false" keeps the column read-only. Once editable, a committed cell edit re-raises the same bubbling lattice:grid-change a config-built grid raises, so a table that carries hx-trigger="lattice:grid-change" can POST the edit.

Row keys on a hydrated table. A <tr>'s synthetic key reads data-lattice-row first, then data-id, then the row index - the same order the out-of-band handler reads it, so a row written <tr data-lattice-row="A2"> is the row a later hx-swap-oob fragment for data-lattice-row="A2" patches. An out-of-band anchor <i id="postable-A2" data-lattice-row="A2"> written next to the table - a sibling of the host after hydration - is claimed too, even though it sits outside the host.

Hydrating with columns merges rather than replaces. A columns entry in the hydrate config refines the column it names while the rest still come from the table: each config column overrides the table column with the same id/field, and every table column it does not name keeps its read definition. A page that wants the old replace behaviour - only the columns it lists - opts out with columns: { only: [...] }.

Hydrate columns merge, and the only opt-out, executed

const { createTestDom, flushFrames } = await import('../packages/dom/src/renderer/testdom.js');
const { hydrateTable } = await import('../packages/dom/src/hydrate.js');

function table(doc) {
  const t = doc.createElement('table');
  const thead = doc.createElement('thead');
  const hr = doc.createElement('tr');
  for (const h of ['Name', 'Qty', 'Active']) {
    const th = doc.createElement('th');
    th.textContent = h;
    hr.appendChild(th);
  }
  thead.appendChild(hr);
  t.appendChild(thead);
  const tb = doc.createElement('tbody');
  const tr = doc.createElement('tr');
  for (const c of ['Alpha', '10', 'true']) {
    const td = doc.createElement('td');
    td.textContent = c;
    tr.appendChild(td);
  }
  tb.appendChild(tr);
  t.appendChild(tb);
  return t;
}

const { root: root1 } = createTestDom();
root1.appendChild(table(root1.ownerDocument));
const g1 = hydrateTable(root1.firstChild, { columns: [{ id: 'qty', field: 'qty', type: 'number' }] });
flushFrames();
const merged = g1.columns.all().map((c) => c.id).join(',');

const { root: root2 } = createTestDom();
root2.appendChild(table(root2.ownerDocument));
const g2 = hydrateTable(root2.firstChild, { columns: { only: [{ id: 'qty', field: 'qty', type: 'number' }] } });
flushFrames();
const only = g2.columns.all().map((c) => c.id).join(',');

g1.destroy();
g2.destroy();

return `merge=${merged} only=${only}`;

Server-driven sort, filter and infinite scroll

import { createGrid, driveServerMode, driveInfiniteScroll } from '@toclocoinc/lattice-grid/modules/htmx';

const grid = createGrid(host, { columns, rows: [], rowKey: 'id' });
const COLUMNS = columns.map(c => ({ field: c.field }));

// Replaces the view outright: fires whenever sort or filter changes.
driveServerMode(grid, document.getElementById('query-trigger'), { columns: COLUMNS });

// Appends the next chunk: fires as the grid's own visible rows near the end.
driveInfiniteScroll(grid, document.getElementById('sentinel'), { columns: COLUMNS });
<div id="query-trigger" hx-get="/rows" hx-trigger="lattice:query-changed" hx-swap="none" hidden></div>
<div id="sentinel" hx-get="/rows" hx-trigger="revealed, lattice:scroll-near-end" hx-swap="none" hidden></div>

Two triggers, two elements, deliberately. A sort or filter change and a scroll asking for more rows are different operations: one replaces every loaded row, the other appends to them, and there is no way to tell the two apart once a response has landed if they share a trigger. Each function configures the request (offset, limit, sort, filters, a small, stable convention any server-side language can read with a JSON parser and a slice) and reads the response back into the grid itself, so hx-swap="none" is required on both: htmx sends the request and nothing else, since a grid is not an HTML swap target.

The sentinel's own trigger names two events for a reason. revealed is htmx's own once-per-element mechanism and gets the very first chunk, firing the moment htmx has processed the element: reliable because it needs nothing from this module's own timing. Every chunk after that fires through lattice:scroll-near-end, which driveInfiniteScroll dispatches once the grid's own visible row window comes within opts.threshold rows (default 20) of what is loaded. That split matters for a fixed-height, virtualised grid specifically: nothing about it ever leaves the page's own viewport once first revealed: new rows land inside the grid's own scroll area, not the page's, so a page-scroll-only trigger would fire exactly once and then go silent. Reading the grid's own render position instead is what makes every later chunk fire on genuine scroll, not on the repaint a successful load causes by itself.

Out-of-band updates. driveOobUpdates(grid, opts) watches for htmx's own out-of-band swaps landing on an element carrying data-lattice-row="<key>", reads the swapped fragment as that row's cells, and applies it to the grid in place: scroll position, selection and filter state are untouched, since nothing about the view is reloaded. A grid built from [data-lattice-grid] markup gets this wiring automatically on htmx:load (and again after a browser-history restore), scoped to its own host and removed when the grid is destroyed, so a declaratively built grid needs no driveOobUpdates call in page code; an explicit call on such a grid is idempotent with the automatic wiring, so a row is never applied twice.

Browser history. On htmx:beforeHistorySave, every live grid's state (serialiseState: sort, filters, column order and widths, scroll position and selection, base64url-encoded and diffed against defaults so an untouched grid costs almost nothing) is written onto its element as data-lattice-state, which rides along in htmx's own history snapshot. On htmx:historyRestore, it is read back and applied: browser back returns a visitor to the sort, filter and scroll position they had, not a blank slate. A grid hydrated from a server-rendered <table> is covered without a second copy of its rows: at hydration the module keeps the original table (and the hydrate config), and at save time it puts that table back in place of the grid's rendered DOM, so the snapshot holds the original markup - no larger than the table the page served, never a rendered grid plus a serialised row dump - and on a cache hit it re-hydrates that table and re-applies the saved state. If a snapshot still cannot be stored (htmx hit its sessionStorage quota, ~5 MB, and dropped the entry), the module warns once by name (htmx:history-snapshot-too-large) and the page degrades to htmx's refreshOnHistoryMiss re-fetch rather than erroring - scope the hx-history-elt subtree to the table, or cap htmx's historyCacheSize. A cache miss (the page was re-fetched from the server) restores nothing, since a fresh response is already the truth.

A failed request leaves the grid alone. driveServerMode and driveInfiniteScroll both listen for htmx:responseError/htmx:sendError; the grid's rows are never touched by a failed request, and a recoverable message is shown through grid.overlay rather than the grid going blank.

What this does not do. It does not call fetch or htmx.ajax() anywhere: htmx owns every request end to end; this only supplies the moment and the parameters, and reads the response back in. It does not import htmx: every htmx-specific call goes through globalThis.htmx, read at call time, so loading this module never requires htmx to already be on the page, only to be present by the time a driven request actually fires. It ships as ESM and as a plain <script src> build with no bundler required, with zero runtime dependencies beyond the grid itself and, at call time, htmx, but because it references the grid's own internals directly rather than the copy already on the page, the bundle carries a full copy of the grid core alongside its own code, the same trade-off the web component and the compatibility wrapper already make.