Lattice Grid Buy a licence

htmx and the data grid

htmx Data Grid from a Server-Rendered Table

An htmx data grid is the plain table your server already renders, marked data-lattice-grid. The module replaces it with a live grid, re-raises each committed edit as lattice:grid-change for hx-post, and restores the grid and its sort on Back. This page runs every step live.

A stand-in server is answering in your browser. This site is static, so there is no backend: a small script intercepts htmx's requests to /htmx-demo/ and replies with HTML built from demo data in memory, after a short delay so each swap is visible. Nothing leaves the page. The routes it runs are printed below, so you can see what your own server would return.

RefTaskOwner StatusPoints
T-101Audit the checkout funnelAna LopezDone5
T-102Redesign the pricing tableDev PatelDoing8
T-103Write the launch emailChloe MarshDoing3
T-104Set up the A/B testBen OkaforTodo5
T-105Landing page for the webinarEva LindqvistTodo8
T-201Move billing to the new ledgerBen OkaforDoing13
T-202Rotate the API keysEva LindqvistDone2
T-203Cache the pricing lookupsFemi AdeyemiDoing5
T-204Retire the v1 webhooksChloe MarshTodo8
T-205Database failover drillDev PatelTodo2

A live grid from a plain table

Each th data-field names a column, data-type sets its type, and data-edit makes it editable. Each row's data-lattice-row is its key, the key the edit event carries back.

The edit is one event

A committed edit reaches the server as the row key, the field and the new value. The reply is swapped in under the grid. Read more about the grid engine under it.

The table is still the fallback

With JavaScript off the table stays a readable table, and the history snapshot holds the table as the server sent it, not a rendered grid, so Back rebuilds the grid once.

Posting an edit with hx-post

The element around the table carries hx-post and hx-trigger="lattice:grid-change"; the event bubbles up to it from the grid that replaces the table. The hx-vals turns the event into form fields for the stand-in server.

The markup and the server template

Both blocks are the code the demo above runs. The first is the page's HTML; the second is the stand-in server's route handlers, which are what a server of yours would do.

Page markup

<div class="view"><div class="filters"><button hx-get="/htmx-demo/grid/tasks?team=" hx-target="#grid-view"
      hx-push-url="/htmx/grid/?team=" aria-pressed="true">All teams</button>
  <button hx-get="/htmx-demo/grid/tasks?team=Growth" hx-target="#grid-view"
      hx-push-url="/htmx/grid/?team=Growth" aria-pressed="false">Growth</button>
  <button hx-get="/htmx-demo/grid/tasks?team=Platform" hx-target="#grid-view"
      hx-push-url="/htmx/grid/?team=Platform" aria-pressed="false">Platform</button></div>
<div hx-post="/htmx-demo/grid/save" hx-trigger="lattice:grid-change"
  hx-vals='js:{key: event.detail.key, field: event.detail.field, value: event.detail.newValue}'
  hx-target="#save-status" hx-swap="innerHTML">
<table id="tasks" data-lattice-grid>
  <thead><tr><th data-field="id">Ref</th><th data-field="title" data-edit>Task</th><th data-field="owner" data-edit>Owner</th>
    <th data-field="status" data-edit>Status</th><th data-field="points" data-type="number" data-edit>Points</th></tr></thead>
  <tbody>
  <tr data-lattice-row="T-101"><td>T-101</td><td>Audit the checkout funnel</td><td>Ana Lopez</td><td>Done</td><td>5</td></tr>
  <tr data-lattice-row="T-102"><td>T-102</td><td>Redesign the pricing table</td><td>Dev Patel</td><td>Doing</td><td>8</td></tr>
  <tr data-lattice-row="T-103"><td>T-103</td><td>Write the launch email</td><td>Chloe Marsh</td><td>Doing</td><td>3</td></tr>
  <tr data-lattice-row="T-104"><td>T-104</td><td>Set up the A/B test</td><td>Ben Okafor</td><td>Todo</td><td>5</td></tr>
  <tr data-lattice-row="T-105"><td>T-105</td><td>Landing page for the webinar</td><td>Eva Lindqvist</td><td>Todo</td><td>8</td></tr>
  <tr data-lattice-row="T-201"><td>T-201</td><td>Move billing to the new ledger</td><td>Ben Okafor</td><td>Doing</td><td>13</td></tr>
  <tr data-lattice-row="T-202"><td>T-202</td><td>Rotate the API keys</td><td>Eva Lindqvist</td><td>Done</td><td>2</td></tr>
  <tr data-lattice-row="T-203"><td>T-203</td><td>Cache the pricing lookups</td><td>Femi Adeyemi</td><td>Doing</td><td>5</td></tr>
  <tr data-lattice-row="T-204"><td>T-204</td><td>Retire the v1 webhooks</td><td>Chloe Marsh</td><td>Todo</td><td>8</td></tr>
  <tr data-lattice-row="T-205"><td>T-205</td><td>Database failover drill</td><td>Dev Patel</td><td>Todo</td><td>2</td></tr>
  </tbody>
</table>
</div></div>

Server template (route handlers)

const esc = (s: unknown) => String(s).replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
const teams = ['Growth', 'Platform'];

const taskRow = (r: (typeof tasks)[number]) =>
  `<tr data-lattice-row="${r.id}"><td>${r.id}</td><td>${esc(r.title)}</td><td>${esc(r.owner)}</td><td>${r.status}</td><td>${r.points}</td></tr>`;

/** The whole view for one team: the picker, and the grid's table inside the element that posts its edits. */
export function gridFragment(team = ''): string {
  const list = team ? tasks.filter((r) => r.team === team) : tasks;
  const picker = ['', ...teams]
    .map((t) => `<button hx-get="/htmx-demo/grid/tasks?team=${t}" hx-target="#grid-view"
      hx-push-url="/htmx/grid/?team=${t}" aria-pressed="${t === team}">${t || 'All teams'}</button>`)
    .join('\n  ');
  return `<div class="view"><div class="filters">${picker}</div>
<div hx-post="/htmx-demo/grid/save" hx-trigger="lattice:grid-change"
  hx-vals='js:{key: event.detail.key, field: event.detail.field, value: event.detail.newValue}'
  hx-target="#save-status" hx-swap="innerHTML">
<table id="tasks" data-lattice-grid>
  <thead><tr><th data-field="id">Ref</th><th data-field="title" data-edit>Task</th><th data-field="owner" data-edit>Owner</th>
    <th data-field="status" data-edit>Status</th><th data-field="points" data-type="number" data-edit>Points</th></tr></thead>
  <tbody>
  ${list.map(taskRow).join('\n  ')}
  </tbody>
</table>
</div></div>`;
}

export const routes: Routes = {
  // hx-get: another team's tasks; the old grid is torn down and a new one built.
  'GET /htmx-demo/grid/tasks': ({ params }) => gridFragment(params.team),

  // hx-post from lattice:grid-change: the key, the field and the new value of the committed cell.
  'POST /htmx-demo/grid/save': ({ params }) => {
    const row = tasks.find((r) => r.id === params.key);
    if (!row || !(params.field in row)) return { html: 'Unknown cell', status: 404 };
    (row as any)[params.field] = params.field === 'points' ? Number(params.value) : params.value;
    return `Server saved ${esc(row.id)}: ${esc(params.field)} is now ${esc(params.value)}`;
  },
};

Common questions

How do I turn a server-rendered table into a live grid with htmx?

Add data-lattice-grid to the table and load the Lattice htmx module: the header row names the columns, the body rows are the data, and one htmx:load replaces the table with the grid. With JavaScript off it is still a readable table.

How do I make a column editable and save the edit?

Put data-edit on the th. A committed cell edit is re-raised as lattice:grid-change with the row key, the field and the new value, so hx-trigger on the table can post it. Edit a cell in the demo and read the server's confirmation.

Does the htmx grid keep its sort when I press Back?

Yes: sort, filters, column widths and scroll are saved with htmx's history snapshot, along with the original table, and reapplied on Back without drawing the grid twice. Sort a column, switch team, then press Back.