developer guide
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.
Developer guide › Using Lattice Grid with htmx
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. 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.
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.
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.
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 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 dhtmlx wrappers already make.