Headless engine First headless grid
First headless grid
createGrid + useSyncExternalStore — render your own table from the engine's snapshot.
A headless grid is two pieces: an engine that owns the state, and your own markup that reads a snapshot of it. The engine never touches the DOM — you map snapshot.visibleRows to whatever you're rendering.
Create the engine
createGrid takes your columns, your rows, and how to identify a row:
import { createGrid } from "@pretable/core";
const grid = createGrid({
columns,
rows,
getRowId: (row) => row.id,
});columns is PretableColumn<TRow>[] — each column has an id, an optional header, and flags like sortable, filterable, plus value / format accessors. See the API reference for the full shape.
Subscribe in React
The engine is a useSyncExternalStore store. Pass getSnapshot as both the client and the server snapshot — the engine's snapshot is deterministic from options, so the server render matches:
const snapshot = useSyncExternalStore(
grid.subscribe,
grid.getSnapshot,
grid.getSnapshot, // server snapshot — engine snapshot is deterministic
);The contract:
subscribe(listener)registers a listener that fires after every mutation, and returns an unsubscribe function.getSnapshot()returns a cached object whose identity stays stable until the next mutation. That stability is what makes ituseSyncExternalStore-safe — it won't loop.
Render snapshot.visibleRows
visibleRows is the filtered + sorted set — the rows that pass the current filter, in sort order. It is not a viewport window; you get every matching row and decide how to render them.
<tbody>
{snapshot.visibleRows.map(({ id, row }) => (
<tr key={id}>
{columns.map((c) => (
<td key={c.id}>{String(row[c.id])}</td>
))}
</tr>
))}
</tbody>Drive it
Call methods on the engine; the snapshot updates and React re-renders:
grid.setSort("latencyMs", "asc"); // sort ascending by a column
grid.setFilter("team", "payments"); // filter a column by substring
grid.toggleRowSelection(rowId); // toggle a row in the selectionLive example
The table below is built exactly this way — createGrid + useSyncExternalStore, no <Pretable>. Click a header to sort, type a team to filter, click a row to select it. Open the source tabs to see the full wiring.
| service-00 | payments | healthy | 20 |
| service-01 | search | degraded | 57 |
| service-02 | identity | down | 94 |
| service-03 | growth | healthy | 131 |
| service-04 | core | degraded | 168 |
| service-05 | payments | down | 205 |
| service-06 | search | healthy | 242 |
| service-07 | identity | degraded | 279 |
| service-08 | growth | down | 316 |
| service-09 | core | healthy | 353 |
| service-10 | payments | degraded | 390 |
| service-11 | search | down | 427 |
| service-12 | identity | healthy | 464 |
| service-13 | growth | degraded | 21 |
| service-14 | core | down | 58 |
| service-15 | payments | healthy | 95 |
| service-16 | search | degraded | 132 |
| service-17 | identity | down | 169 |
| service-18 | growth | healthy | 206 |
| service-19 | core | degraded | 243 |
| service-20 | payments | down | 280 |
| service-21 | search | healthy | 317 |
| service-22 | identity | degraded | 354 |
| service-23 | growth | down | 391 |
| service-24 | core | healthy | 428 |
| service-25 | payments | degraded | 465 |
| service-26 | search | down | 22 |
| service-27 | identity | healthy | 59 |
| service-28 | growth | degraded | 96 |
| service-29 | core | down | 133 |
| service-30 | payments | healthy | 170 |
| service-31 | search | degraded | 207 |
| service-32 | identity | down | 244 |
| service-33 | growth | healthy | 281 |
| service-34 | core | degraded | 318 |
| service-35 | payments | down | 355 |
| service-36 | search | healthy | 392 |
| service-37 | identity | degraded | 429 |
| service-38 | growth | down | 466 |
| service-39 | core | healthy | 23 |
| service-40 | payments | degraded | 60 |
| service-41 | search | down | 97 |
| service-42 | identity | healthy | 134 |
| service-43 | growth | degraded | 171 |
| service-44 | core | down | 208 |
| service-45 | payments | healthy | 245 |
| service-46 | search | degraded | 282 |
| service-47 | identity | down | 319 |
| service-48 | growth | healthy | 356 |
| service-49 | core | degraded | 393 |
| service-50 | payments | down | 430 |
| service-51 | search | healthy | 467 |
| service-52 | identity | degraded | 24 |
| service-53 | growth | down | 61 |
| service-54 | core | healthy | 98 |
| service-55 | payments | degraded | 135 |
| service-56 | search | down | 172 |
| service-57 | identity | healthy | 209 |
| service-58 | growth | degraded | 246 |
| service-59 | core | down | 283 |
| service-60 | payments | healthy | 320 |
| service-61 | search | degraded | 357 |
| service-62 | identity | down | 394 |
| service-63 | growth | healthy | 431 |
| service-64 | core | degraded | 468 |
| service-65 | payments | down | 25 |
| service-66 | search | healthy | 62 |
| service-67 | identity | degraded | 99 |
| service-68 | growth | down | 136 |
| service-69 | core | healthy | 173 |
| service-70 | payments | degraded | 210 |
| service-71 | search | down | 247 |
| service-72 | identity | healthy | 284 |
| service-73 | growth | degraded | 321 |
| service-74 | core | down | 358 |
"use client";
import { useState, useSyncExternalStore } from "react";
import { createGrid, type PretableSortDirection } from "@pretable/core";
import { columns } from "./columns";
import { services } from "./data";
export function HeadlessTable() {
// The engine is created once and owns all grid state.
const [grid] = useState(() =>
createGrid({ columns, rows: services, getRowId: (r) => r.id }),
);
// Subscribe the component to engine changes. getSnapshot is memoized by the
// engine until the next mutation, so it is safe as the store snapshot.
const snapshot = useSyncExternalStore(
grid.subscribe,
grid.getSnapshot,
grid.getSnapshot,
);
// Each toggleRowSelection range is a single full-width row
// (startRowId === endRowId), so selected ids read back directly.
const selectedIds = new Set(
snapshot.selection.ranges
.filter((r) => r.startRowId === r.endRowId)
.map((r) => r.startRowId),
);
const toggleSort = (columnId: string) => {
const current = snapshot.sort;
const next: PretableSortDirection =
current.columnId !== columnId
? "asc"
: current.direction === "asc"
? "desc"
: current.direction === "desc"
? null
: "asc";
grid.setSort(next ? columnId : null, next);
};
return (
<div>
<label style={{ display: "block", marginBottom: 8, fontSize: 13 }}>
Filter by team{" "}
<input
aria-label="Filter by team"
defaultValue=""
onChange={(e) => grid.setFilter("team", e.target.value)}
/>
</label>
<table>
<thead>
<tr>
{columns.map((c) => (
<th key={c.id} scope="col">
{c.sortable ? (
<button type="button" onClick={() => toggleSort(c.id)}>
{c.header ?? c.id}
{snapshot.sort.columnId === c.id
? snapshot.sort.direction === "asc"
? " ▲"
: snapshot.sort.direction === "desc"
? " ▼"
: ""
: ""}
</button>
) : (
(c.header ?? c.id)
)}
</th>
))}
</tr>
</thead>
<tbody>
{snapshot.visibleRows.map(({ id, row }) => (
<tr
key={id}
aria-selected={selectedIds.has(id)}
onClick={() => grid.toggleRowSelection(id)}
>
{columns.map((c) => (
<td key={c.id}>{String(row[c.id as keyof typeof row])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}