Headless engine Snapshot & subscribe

Snapshot & subscribe

The indexed row-model snapshot, UI-state snapshot, and subscription contracts.

The row model and UI grid are independent observable stores. Subscribe only to the state your renderer uses.

A setQuery that changes row grouping does not settle synchronously — the model rebuilds cooperatively, yielding between slices so a large query cannot block the frame. Sort-only and filter-only changes on ungrouped data are the exception: each re-orders or re-selects rows the model has already indexed, so it settles synchronously and never publishes a rebuilding phase — plain sorting and filtering need no progress UI. Grouped and mixed changes still rebuild cooperatively. On a small dataset even the cooperative rebuild is over before a human (or React) can see it happen, which is why the button below groups 150,000 rows instead of 75: watch status cycle through rebuilding with a live percentage, then settle back to ready.

Watching a rebuild

Grouping 150,000 rows cannot settle inside one animation frame, so status.kind cycles through rebuilding with a live completedRows/totalRows progress readout before returning to ready.

.md

The RebuildProgress readout above is its own component, subscribed only to status. The table underneath it subscribes only to snapshot and never re-renders mid-rebuild. That split is the pattern this page documents.

Row-model state

rowModel.getState() returns { snapshot, status }.

FieldMeaning
snapshot.revisionMonotonic committed revision; the initial revision is 0.
snapshot.sourceRowCountSource rows before filtering.
snapshot.visibleRowCountVisible data and group rows.
snapshot.visibleDataRowCountVisible data rows only.
snapshot.queryCurrent typed filters, ordered sort, and row groups.
snapshot.expansionExpansion default plus sparse override count.
statusAn object, discriminated on status.kind — see below.

status is a discriminated union, not a string — compare status.kind, never status itself. That's the value the progress readout above is watching climb:

status.kindAlso carriesMeans
readyThe snapshot is the committed result of the current query.
rebuildingtransitionId, completedRows, totalRowsA rebuild is in flight. Keep reading the snapshot — see below.
errortransitionId, errorThe rebuild failed. The snapshot is the last one that committed.
disposedThe model was disposed; it accepts no further operations.

rebuilding does not mean the snapshot is frozen. It means the in-flight query has not been applied yet — the snapshot is still the last one that committed, and mutations keep committing into it meanwhile: setRows, applyTransaction and both expansion paths publish a new snapshot while a rebuild runs. A renderer that stops re-reading the snapshot during a rebuild drops those, which is exactly the streaming-plus-filter case. The table in the example above stays on the last committed result throughout, exactly like this.

completedRows and totalRows count the rebuild's work units, not rows. Grouping and concurrent mutations add units as the transition runs, so totalRows can exceed the source row count and can grow mid-rebuild — which is why the readout above reports a percentage rather than "row 40,000 of 150,000". Use them as a progress ratio; do not label them as rows.

Select what you subscribe to. getState() returns a fresh object on every cooperative slice, so handing it whole to useSyncExternalStore re-renders the consumer on each one. Select the snapshot — stable until the swap — for the table, exactly as the example's RebuildProgressDemo does. For progress or errors, don't select status.kind alone — it can't tell you the percentage — select a derived string built from status, the way RebuildProgress does: `rebuilding:${pct}` while a rebuild is running, or the bare kind otherwise. A string still lets useSyncExternalStore bail out on identity between slices that round to the same percentage, while giving you something a number or the status object itself can't: a value that actually changes as the rebuild progresses. Subscribing to the whole state object instead cost the smaller custom-renderer example ~1.9s of per-slice repainting for a rebuild the model finishes in ~4ms — see that example for the measurement.

A renderer that ignores status altogether shows stale rows after a failed rebuild with nothing to indicate it, because on error the snapshot it is reading is the last one that committed.

Rows are indexed:

ts
snapshot.rowAt(index);
snapshot.range(start, end); // half-open and clamped
snapshot.indexOf({ kind: "data", rowId });
snapshot.dataRowAt(dataIndex);
snapshot.firstDataRow();
snapshot.nextDataRow(ref);

Captured snapshots are immutable revision roots. getState() returns the same object identity until either its snapshot or status changes.

UI-state snapshot

When you create createGrid({ rowModel, columns }), grid.getState() contains only presentation state:

  • viewport
  • discriminated focus
  • indexed selection
  • editing
  • columnLayout
  • the last row-model revision observed by layout

Grid notifications do not proxy row-model notifications. A custom renderer that uses both should subscribe to both stores.

ts
const unsubscribeModel = rowModel.subscribe(render);
const unsubscribeGrid = grid.subscribe(render);
 
function render() {
  const { snapshot, status } = rowModel.getState();
  const ui = grid.getState();
  // Read only the range required by this frame.
}

Unsubscribing is idempotent. No-op commands do not notify.

Server rendering

Create the row model from the same immutable inputs on the server and first client render, and use getState as the server snapshot for useSyncExternalStore.

Next

Actions

Mutate row/query state through the row model and UI state through the grid.