Headless engine First headless grid

First headless grid

Create a local row model and render an indexed snapshot range.

A headless renderer starts with createLocalRowModel. Add createGrid only when your renderer needs UI state. The grid below is exactly that: 75 services rendered from a plain <table>, with createLocalRowModel driving sort and filter and createGrid driving row selection.

setQuery does not settle synchronously in the general case — the model rebuilds cooperatively, yielding between slices so a large query cannot block the frame, and publishes progress and failures through status. Sort-only and filter-only changes on ungrouped data are the exception, including typing into the filter here: each re-orders or re-selects rows the model has already indexed, so it settles synchronously with no rebuilding phase. Grouped and mixed changes still rebuild cooperatively — see Snapshot & subscribe for a demo. Two things are still worth doing, and the example does both: select what you subscribe to, so a cooperative rebuild elsewhere doesn't re-render you on every slice, and read status, so a failed rebuild doesn't leave stale rows on screen with nothing to say so.

Headless custom renderer

Drive your own markup from the @pretable/core row model with useSyncExternalStore — no grid renderer involved.

.md

The rest of this page walks through how that table is built, piece by piece.

Create typed columns and a row model

The example above declares its columns exactly this way — columns.ts calls createColumnHelper<Service>() once, then column.accessor(...) per field:

columns.ts
import { createColumnHelper, createLocalRowModel } from "@pretable/core";
 
interface Service {
  id: string;
  name: string;
  latencyMs: number;
}
 
const column = createColumnHelper<Service>();
const columns = [
  column.accessor("name", { type: "text", header: "Service" }),
  column.accessor("latencyMs", {
    type: "number",
    header: "Latency",
  }),
] as const;
 
const rowModel = createLocalRowModel({ rows, columns });

The helper preserves each column's ID and value type. createLocalRowModel infers the row, row ID, and complete column tuple — nothing above is manually typed.

Subscribe

The example's readSnapshot callback, passed to useSyncExternalStore, is this in its shortest form:

tsx
const state = useSyncExternalStore(
  rowModel.subscribe,
  rowModel.getState,
  rowModel.getState,
);
const snapshot = state.snapshot;

getState() is referentially stable until the committed snapshot or observable status changes. The example goes one step further and selects .snapshot directly rather than the whole state — see Snapshot & subscribe for why that's what keeps a rebuild from re-rendering the table on every slice.

Render an indexed range

The <tbody> in the example above requests only the half-open window it plans to render. Bounds are clamped to [0, snapshot.visibleRowCount]:

tsx
const start = 0;
const end = Math.min(snapshot.visibleRowCount, 100);
 
<tbody>
  {snapshot.range(start, end).map((entry) =>
    entry.kind === "data" ? (
      <tr key={entry.rowId}>
        <td>{entry.row.name}</td>
        <td>{entry.row.latencyMs}</td>
      </tr>
    ) : (
      <tr key={entry.groupId}>
        <th colSpan={2}>{String(entry.value)}</th>
      </tr>
    ),
  )}
</tbody>;

Add UI state when needed

Row selection in the example — click a row, and aria-selected follows — is createGrid plus grid.toggleRowSelection. Focus works the same way:

ts
import { createGrid } from "@pretable/core";
 
const grid = createGrid({ rowModel, columns });
grid.setFocus({ ref: { kind: "data", rowId: "svc-1" }, columnId: "name" });

The UI grid does not own rows or query state. For the complete typed column and row-model contracts, see the API reference.

Next