Lattice Grid Buy a licence

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.