Store

GridStorm uses a lightweight reactive store purpose-built for grid state management. The store holds the entire GridState object and notifies subscribers whenever state changes. It supports batched updates to coalesce multiple mutations into a single notification cycle, memoized selectors for efficient derived data, and re-entrancy protection to prevent infinite update loops.

How the Store Works

The store follows a simple pattern: state lives in a single immutable-by-convention object. You read state with getState(), update it with setState(), and react to changes with subscribe() or select(). Every call to setState increments an internal version counter and notifies all listeners -- unless the update is inside a batch(), in which case listeners fire once at the end.

Commands are the only sanctioned way to mutate state in production code. Plugins and the engine dispatch commands, and command handlers call store.setState() internally. You rarely call setState directly unless you are writing a plugin.

Installation

The store is part of @gridstorm/core and is created automatically when you initialize a grid engine. You do not install it separately.

pnpm add @gridstorm/core

State Shape (GridState)

The GridState interface represents the complete internal state of a grid instance:

Property Type Description
columns ColumnState[] Resolved column states derived from column definitions
rowNodes Map<string, RowNode> All row nodes keyed by unique ID
displayedRowIds string[] Ordered row IDs after sort, filter, group, and pagination
sortModel SortModelItem[] Current sort configuration
filterModel Record<string, FilterModel> Active filters keyed by column ID
selection { selectedRowIds, rangeSelections } Row and range selection state
editing EditingState | null Current cell editing state
scroll { top, left } Viewport scroll position in pixels
focusedCell CellPosition | null Currently focused cell
pagination { currentPage, pageSize, totalRows } Pagination state
quickFilterText string Quick filter search text
columnGroups ColumnGroupInfo[] Multi-level header group hierarchy
columnGroupDepth number Max nesting depth of column groups
pluginState Record<string, unknown> Plugin-managed state slices

Reading State

Use getState() to read the current state snapshot:

const state = engine.store.getState();

console.log('Displayed rows:', state.displayedRowIds.length);
console.log('Sort model:', state.sortModel);
console.log('Selected row count:', state.selection.selectedRowIds.size);

From the public API, use api.getState():

const state = api.getState();
const visibleColumns = state.columns.filter((c) => !c.hide);

Updating State

Pass an updater function to setState(). The updater receives the previous state and must return a new state object. If the returned object is referentially identical to the previous state, no notification occurs:

engine.store.setState((prev) => ({
  ...prev,
  quickFilterText: 'engineering',
}));

In plugin code, you access the store through the PluginContext:

install(ctx) {
  ctx.store.setState((prev) => ({
    ...prev,
    sortModel: [{ colId: 'name', sort: 'asc' }],
  }));
}

Subscribing to Changes

subscribe() registers a listener that fires after every state change. It returns an unsubscribe function:

const unsub = engine.store.subscribe(() => {
  const state = engine.store.getState();
  console.log('State version:', engine.store.getVersion());
  console.log('Row count:', state.displayedRowIds.length);
});

// Later: clean up
unsub();

Selectors

The select() method has two overloads. Without a listener, it runs a selector function against the current state and returns the result immediately:

const sortModel = engine.store.select((state) => state.sortModel);
console.log('Current sort:', sortModel);

With a listener, it subscribes to a specific slice of state. The listener only fires when the selected value changes by reference equality -- much more efficient than subscribing to every state change:

const unsub = engine.store.select(
  (state) => state.selection.selectedRowIds,
  (next, prev) => {
    console.log('Selection changed from', prev.size, 'to', next.size);
  },
);

Memoized Selectors with createSelector

For derived data that depends on multiple state slices, use createSelector. It only recomputes when its dependencies change (shallow reference equality):

import { createSelector } from '@gridstorm/core';

const selectVisibleSelectedCount = createSelector(
  [
    (state) => state.displayedRowIds,
    (state) => state.selection.selectedRowIds,
  ],
  (displayedIds, selectedIds) => {
    return displayedIds.filter((id) => selectedIds.has(id)).length;
  },
);

// Use it:
const count = engine.store.select(selectVisibleSelectedCount);
console.log('Visible selected rows:', count);

You can also combine createSelector with the subscription overload of select():

const unsub = engine.store.select(
  selectVisibleSelectedCount,
  (next, prev) => {
    console.log('Visible selection changed:', prev, '->', next);
  },
);

Batched Updates

When you need to make multiple state changes that should result in a single notification cycle, wrap them in batch():

engine.store.batch(() => {
  engine.store.setState((prev) => ({
    ...prev,
    sortModel: [{ colId: 'name', sort: 'asc' }],
  }));
  engine.store.setState((prev) => ({
    ...prev,
    filterModel: {},
  }));
});
// Listeners fire only once here, with both changes applied

Batches can be nested. Listeners only fire when the outermost batch completes:

engine.store.batch(() => {
  engine.store.setState(/* ... */);
  engine.store.batch(() => {
    engine.store.setState(/* ... */);
  });
  // inner batch does NOT trigger listeners yet
});
// listeners fire once here

Re-Entrancy Protection

If a listener calls setState() during notification (re-entrant update), the update is queued and applied after the current notification cycle completes. This prevents stack overflows and ensures all listeners see a consistent state during each notification pass.

The store limits queued re-entrant updates to 100 iterations. If this limit is reached, it logs an error and breaks the cycle to prevent infinite loops.

engine.store.subscribe(() => {
  const state = engine.store.getState();
  if (state.quickFilterText === 'trigger') {
    // This setState is queued, not applied immediately
    engine.store.setState((prev) => ({
      ...prev,
      quickFilterText: 'resolved',
    }));
  }
});

Version Counter

Every state change increments an internal version counter. Use getVersion() to cheaply detect whether state has changed since you last checked:

let lastVersion = engine.store.getVersion();

function checkForChanges() {
  const currentVersion = engine.store.getVersion();
  if (currentVersion !== lastVersion) {
    lastVersion = currentVersion;
    console.log('State changed!');
  }
}

Next Steps