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:

ts
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:

tsx
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 it useSyncExternalStore-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.

tsx
<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:

ts
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 selection

Live 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.

Headless custom renderer — live
service-00paymentshealthy20
service-01searchdegraded57
service-02identitydown94
service-03growthhealthy131
service-04coredegraded168
service-05paymentsdown205
service-06searchhealthy242
service-07identitydegraded279
service-08growthdown316
service-09corehealthy353
service-10paymentsdegraded390
service-11searchdown427
service-12identityhealthy464
service-13growthdegraded21
service-14coredown58
service-15paymentshealthy95
service-16searchdegraded132
service-17identitydown169
service-18growthhealthy206
service-19coredegraded243
service-20paymentsdown280
service-21searchhealthy317
service-22identitydegraded354
service-23growthdown391
service-24corehealthy428
service-25paymentsdegraded465
service-26searchdown22
service-27identityhealthy59
service-28growthdegraded96
service-29coredown133
service-30paymentshealthy170
service-31searchdegraded207
service-32identitydown244
service-33growthhealthy281
service-34coredegraded318
service-35paymentsdown355
service-36searchhealthy392
service-37identitydegraded429
service-38growthdown466
service-39corehealthy23
service-40paymentsdegraded60
service-41searchdown97
service-42identityhealthy134
service-43growthdegraded171
service-44coredown208
service-45paymentshealthy245
service-46searchdegraded282
service-47identitydown319
service-48growthhealthy356
service-49coredegraded393
service-50paymentsdown430
service-51searchhealthy467
service-52identitydegraded24
service-53growthdown61
service-54corehealthy98
service-55paymentsdegraded135
service-56searchdown172
service-57identityhealthy209
service-58growthdegraded246
service-59coredown283
service-60paymentshealthy320
service-61searchdegraded357
service-62identitydown394
service-63growthhealthy431
service-64coredegraded468
service-65paymentsdown25
service-66searchhealthy62
service-67identitydegraded99
service-68growthdown136
service-69corehealthy173
service-70paymentsdegraded210
service-71searchdown247
service-72identityhealthy284
service-73growthdegraded321
service-74coredown358
"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>
  );
}

Next