Headless engine Snapshot & subscribe

Snapshot & subscribe

The getSnapshot shape, the subscribe contract, and useSyncExternalStore integration.

getSnapshot() returns a PretableGridSnapshot<TRow> — the complete, current state of the engine. subscribe(listener) tells you when it changes. Together they're the whole read side of the engine.

The snapshot shape

FieldTypeWhat it is
visibleRowsPretableVisibleRow<TRow>[]The filtered + sorted row set — rows passing the current filter, in sort order. Not viewport-windowed.
visibleRangePretableRowRange{ start, end }. Currently the full range { start: 0, end: visibleRows.length }.
totalRowCountnumberCount of source rows, before filtering.
sortPretableSortState{ columnId, direction } — the active sort.
filtersRecord<string, string>Active per-column filter values, keyed by column id.
selectionPretableSelectionState{ anchor, ranges } — the current cell-range selection.
focusPretableFocusState{ rowId, columnId } — the focused cell.
viewportPretableViewportState{ scrollTop, scrollLeft, width, height }.

visibleRows

Each entry is a PretableVisibleRow<TRow>:

ts
interface PretableVisibleRow<TRow> { id: string; // the row id from getRowId row: TRow; // the source row object sourceIndex: number; // index of the row in the original rows array }

This is the array you render. It already reflects the active filter and sort, so you map it directly to markup — no client-side filtering or sorting on your end.

visibleRange

visibleRange is { start, end } and is currently always the full range — { start: 0, end: visibleRows.length }. It is reserved for future windowing. Do not treat it as a viewport slice; render the rows in visibleRows, not a slice indexed by visibleRange.

viewport

viewport holds { scrollTop, scrollLeft, width, height }. The engine uses it for one thing only: PageUp / PageDown focus math in moveFocus. It does not decide which rows visibleRows returns — the engine never windows rows by viewport.

The subscribe contract

ts
const unsubscribe = grid.subscribe(() => { // fires after every mutation render(grid.getSnapshot()); }); unsubscribe(); // stop listening
  • subscribe(listener) returns an unsubscribe function.
  • The listener fires after each effective state change (a no-op mutation, like re-applying the current sort, doesn't notify).
  • Pair it with getSnapshot() — the listener is the "something changed" signal; the snapshot is the new state.

Snapshot identity is stable

getSnapshot() returns the same object reference until the next mutation — the engine memoizes the snapshot. That stable identity is what makes the store safe for useSyncExternalStore: React can compare snapshots by reference, so a subscribe notification that doesn't change state won't cause a render loop.

Server-side rendering

useSyncExternalStore takes a third argument for the server snapshot. Pass getSnapshot again:

tsx
const snapshot = useSyncExternalStore( grid.subscribe, grid.getSnapshot, grid.getSnapshot, // server snapshot );

The engine's snapshot is deterministic from the options you passed to createGrid, so the server render and the first client render produce the same visibleRows — no hydration mismatch.

Next

Actions

Every mutation that changes the snapshot: sort, filter, select, focus, column layout, and data transactions.