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.