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.
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.
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 }.
| Field | Meaning |
|---|---|
snapshot.revision | Monotonic committed revision; the initial revision is 0. |
snapshot.sourceRowCount | Source rows before filtering. |
snapshot.visibleRowCount | Visible data and group rows. |
snapshot.visibleDataRowCount | Visible data rows only. |
snapshot.query | Current typed filters, ordered sort, and row groups. |
snapshot.expansion | Expansion default plus sparse override count. |
status | An 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.kind | Also carries | Means |
|---|---|---|
ready | — | The snapshot is the committed result of the current query. |
rebuilding | transitionId, completedRows, totalRows | A rebuild is in flight. Keep reading the snapshot — see below. |
error | transitionId, error | The rebuild failed. The snapshot is the last one that committed. |
disposed | — | The 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:
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 editingcolumnLayout- 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.
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.