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.
Drive your own markup from the @pretable/core row model with useSyncExternalStore — no grid renderer involved.
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:
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:
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]:
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:
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.