Skip to content

State Model & Machine

Depends on: Philosophy

State: Structure

Two slices (focus, selection) plus editing state (draft, original).

ComponentResponsibilityChanges when
focusWhich element is active + interaction modeArrow keys, click, Tab, Enter, F2, Escape
selectionWhich cells are selectedClick, Shift+Click, Ctrl+A, keyboard with Shift
draftCurrent value during editingTyping in editor
originalValue before editing startedEnter edit mode

Focus

FieldTypeDescription
targetCellRef | HeaderRef | nullCurrently focused element
mode'navigation' | 'edit' | 'interactive'Determines how keyboard input is interpreted

Focus Modes:

ModeArrow KeysEnter/F2EscapeUse Case
navigationMove between cellsEnter edit/interactiveClear selectionDefault grid navigation
editCursor in inputCommit + exitCancel + exitInline text editing
interactiveInteractive-specificInteractive-specificExit to navigationComplex interactives (combobox, etc)

CellRef = { type: 'cell', rowId: string, colId: string }HeaderRef = { type: 'header', colId: string }

Stable IDs (not indices) because they survive sort/filter/virtualization.

Selection

FieldTypeDescription
rangesSelectionRange[]Array of selected ranges
anchorCellRef | nullStarting point for Shift extension

SelectionRange = { start: CellRef, end: CellRef }

v1: single contiguous range. Array structure for future extensibility (multi-range with Ctrl).

Editing State

FieldTypeDescription
draftunknown | nullCurrent value in editor (null if not editing)
originalunknown | nullValue before edit (null if not editing)

Note: draft and original are only set when focus.mode === 'edit'. In other modes they are null.

Why draft: unknown?

Different columns have different data types. The library doesn't know if a cell contains a string, number, date, or complex object. Userland knows the column type and performs type narrowing in onCommit:

typescript
onCommit: ({ cell, value }) => {
  if (cell.colId === "price") {
    const price = value as number; // userland knows this column is numeric
    updatePrice(cell.rowId, price);
  }
};

This aligns with the headless philosophy: maximum flexibility, userland controls specifics.

Interactive Mode Responsibility

When focus.mode === 'interactive', the library does not manage internal interactive state. Interactive-specific keyboard handling is userland's responsibility.

ResponsibilityLibraryUserland
Enter interactive modeDispatches ENTER_WIDGET_MODERenders interactive UI
Arrow keysPasses through (no-op)Handles within interactive
Tab keyExits interactive mode if appropriateMay cycle internal focusables
Escape keyExits to navigation modeMay close popups first
Interactive stateManages (dropdown open, selection)

Pattern for combobox in cell:

typescript
// Userland handles keyboard when in interactive mode
onBefore: (action) => {
  if (action.type === "KEY_DOWN" && state.focus.mode === "interactive") {
    if (action.key === "ArrowDown") {
      // Handle dropdown navigation
      return false; // Prevent library from processing
    }
  }
  return true;
};

State: Ownership

Internal state managed by library.

Internal Zustand store, userland accesses via selectors. Controlled pattern (state/onStateChange) deferred to future versions.

AspectBehavior
Who owns stateLibrary (internal Zustand store)
How userland readsSelectors: useGridSelector, useCellSelector
How userland modifiesdispatch(action)
InitializationDefault initial state, override via optional initialState

Transitions

Model: pure transition function that returns new state + effects.

transition(state, action, context) → { state: State, effects: Effect[] }
ComponentRole
stateCurrent state (immutable)
actionEvent that triggers change
contextRead-only external info (grid IDs, config)
effectsDescriptions of side-effects to execute post-render

Context

Information that the transition function reads but doesn't modify:

FieldSourceUse
rowIdsTanStack TableArray of row IDs in visual order, navigation + bounds
colIdsTanStack TableArray of column IDs in visual order, navigation + bounds
isEditable(cellRef)Config/column defCan enter edit mode?
configUserlandOptions (keyboard bindings, a11y settings)

Derivation from TanStack:

typescript
// useGridBehavior accepts any object with .id property
rows: table.getRowModel().rows,       // Row[] - each has .id
columns: table.getVisibleLeafColumns(), // Column[] - each has .id

IDs are extracted internally. Navigation uses rowIds.indexOf(currentId) to find index, rowIds[index + 1] for next cell.

Action Catalog

Focus Actions

ActionTriggerState Changes
FOCUS_CELLClick on cellfocus.target → cell
FOCUS_HEADERClick on headerfocus.target → header
MOVE_FOCUSArrow keys (navigation)focus.target → adjacent cell
BLUR_GRIDFocus exits gridfocus.target → null, mode → navigation

Mode Transition Actions

ActionTriggerState Changes
ENTER_EDIT_MODEEnter, F2, typingfocus.mode → edit, draft/original set
EXIT_EDIT_MODEEnter, Tab, Escape, blurfocus.mode → navigation, draft/original → null
ENTER_WIDGET_MODETab in cell with focusablesfocus.mode → interactive
EXIT_WIDGET_MODEEscape, Tab outfocus.mode → navigation
UPDATE_DRAFTTyping in editordraft updated

EXIT_EDIT_MODE variants:

VariantTriggerEffect GeneratedDraft Handling
commitEnter, Tab, blurCOMMIT_VALUEValue persisted
cancelEscapeNoneValue discarded

Selection Actions

ActionTriggerState Changes
SELECT_CELLClickselection.ranges → single cell, anchor set
EXTEND_SELECTIONShift+Click, Shift+Arrowselection.ranges extended from anchor
SELECT_ALLCtrl+Aselection.ranges → all cells
CLEAR_SELECTIONEscape, click elsewhereselection.ranges → empty

Clipboard Actions

ActionTriggerEffect Generated
COPYCtrl+CWRITE_CLIPBOARD
PASTECtrl+VPASTE_DATA (userland handles)
DELETEDelete, BackspaceDELETE_VALUES (userland handles)

Mode Transition Rules

Transitions between focus modes follow strict rules:

            ┌─────────────────────────────────────────┐
            │                                         │
            ▼                                         │
    ┌──────────────┐     Enter/F2/type    ┌─────────────────┐
    │  navigation  │ ──────────────────►  │      edit       │
    └──────────────┘                      └─────────────────┘
            │                                    │
            │ Tab (focusables)            Escape │ Enter/Tab
            ▼                                    ▼
    ┌──────────────┐     Escape           ┌─────────────────┐
    │  interactive │ ◄────────────────────│   navigation    │
    └──────────────┘                      └─────────────────┘

            │ Escape/Tab out

    ┌──────────────┐
    │  navigation  │
    └──────────────┘
FromToTriggerPreconditions
navigationeditEnter, F2, or printable keyCell is editable
navigationinteractiveTab when cell has focusablesCell has focusable children
editnavigationEnter (commit), Escape (cancel)
editnavigationTab (commit + move)Moves focus to next cell
interactivenavigationEscape, or Tab on last element

Implicit transitions:

ScenarioBehavior
MOVE_FOCUS while mode === editImplicit commit, then move
Click other cell while editingImplicit commit, focus new cell
Grid loses focus while editingCommit if commitOnBlur: true

Effects

Descriptions of side-effects, executed after state update.

Why Effects from Transitions (not reactive)

ProCon
Precise timing (focus after DOM update)More initial structure
Traceability (action X → effects Y)Indirection (effect → executor)
Testability (assert on array, no DOM)Context must pass info for effects
Extensibility (new effect type, not scattered useEffects)

Rejected alternative: useEffect on state changes. Problems: unpredictable timing, spaghetti with multiple behaviors, hard to debug ("why did this effect fire?").

EffectWhen generatedWhat it does
FOCUS_ELEMENTAfter MOVE_FOCUS, FOCUS_CELLelement.focus()
SCROLL_INTO_VIEWAfter navigationelement.scrollIntoView() or virtualizer.scrollToIndex
WRITE_CLIPBOARDAfter COPYnavigator.clipboard.write()
ANNOUNCEAfter navigation, mode changes (a11y)Update ARIA live region
COMMIT_VALUEAfter EXIT_EDIT_MODE (commit variant)Calls userland onCommit callback
PASTE_DATAAfter PASTECalls userland onPaste callback with parsed data
DELETE_VALUESAfter DELETECalls userland onDelete callback

FOCUS_ELEMENT Behavior

FOCUS_ELEMENT is a no-op if focus is already on the target element or one of its children. This prevents race conditions during editing:

  1. Double-click on cell → SELECT_CELL queues FOCUS_ELEMENT via RAF
  2. Editor input renders with autoFocus
  3. RAF fires, but input (child of cell) already has focus
  4. FOCUS_ELEMENT skips element.focus() to avoid stealing focus from editor

Without this check, focus would steal from the input, triggering blur → cancel edit.

Effect Execution

Sequence:

  1. dispatch(action) called
  2. transition(state, action, ctx) executed (pure)
  3. store.setState(newState) — React schedules re-render
  4. After DOM commit, effects executed in order via requestAnimationFrame

Userland can intercept:

typescript
onEffect: (effect) => {
  if (effect.type === "SCROLL_INTO_VIEW") {
    // custom scroll with virtualizer
    return true; // handled
  }
  return false; // default handling
};

Error Handling

Philosophy: simple, predictable, userland controls recovery.

ScenarioBehavior
onCommit throwsError bubbles up. Library doesn't catch. Userland should try/catch in callback.
onPaste throwsSame — error bubbles up
onDelete throwsSame — error bubbles up
isEditable throwsTreated as false (cell not editable). Error logged to console.
Clipboard API unavailableSilent no-op. Effect completes without action.
Effect execution throwsError logged, subsequent effects still execute.

Rationale: The library doesn't know how to recover from userland errors. Different apps have different error handling strategies (toast, modal, retry). By letting errors bubble, userland has full control.

Recommended pattern for userland:

typescript
onCommit: async ({ cell, value }) => {
  try {
    await saveToServer(cell.rowId, cell.colId, value);
  } catch (error) {
    showErrorToast("Save failed");
    // Optionally: dispatch action to revert or re-enter edit mode
  }
};

Userland API for Reactivity

Selectors (granular reading)

HookScopeNotification complexity
useGridSelector(grid, selector)Global stateO(listeners) on every change
useCellSelector(grid, cellRef, selector)Single cellO(1) — internal map cellKey → subscribers

useCellSelector necessary for performance: 1000 cells, focus changes → 2 notifications (old/new), not 1000 recalculations.

Lifecycle Hooks (sync interception)

HookSignaturePurpose
onBefore(action) => booleanBlock any action if false
onAfter(action, state) => voidReact to any completed action

Async pattern: hook returns false, userland does async check, then manually dispatches if ok.

Callbacks (post-transition side-effects)

CallbackWhenPayload
onCommitEdit committed{ cell, value, original }
onPastePaste detected{ startCell, data: string[][] }
onDeleteDelete requested{ cells: CellRef[] }
onSelectionChangeSelection changes{ ranges }

Fire-and-forget, don't block transitions.

Performance Considerations

ProblemSolution
Cascade re-render on focus changeGranular selectors: useFocusedCell() only for those who need it
Many cells, few selectedSelection check O(1): internal Set, not range iteration
VirtualizationStable IDs, not indices. SCROLL_INTO_VIEW effect interceptable
Frequent draft updates during editDraft isolated, only editor component subscribes to draft
Mode changesMode is part of focus, minimal re-renders via selectors

Invariants

Conditions always true, transitions maintain them:

  1. Edit mode requires focus: If focus.mode === 'edit', then focus.target is a CellRef (not null, not header)
  2. Edit state consistency: If focus.mode === 'edit', then draft !== null && original !== null
  3. Navigation mode has no draft: If focus.mode === 'navigation', then draft === null && original === null
  4. Interactive mode requires focus: If focus.mode === 'interactive', then focus.target !== null
  5. Anchor in range: selection.anchor is always inside one of selection.ranges (or null if ranges empty)
  6. Valid IDs: IDs in state (rowId, colId) exist in current context (or transition invalidates them)
  7. Headers not editable: If focus.target is a HeaderRef, then focus.mode cannot be 'edit'

Flow

User Event (click/key/paste)


┌─ Event Handler ─────────────────┐
│  Constructs Action              │
└─────────────────────────────────┘


┌─ onBefore(action) ──────────────┐
│  return false? ─────────► STOP  │
└─────────────────────────────────┘
       │ true

┌─ Transition (pure) ─────────────┐
│  (state, action, ctx)           │
│  → { state, effects[] }         │
└─────────────────────────────────┘

       ├──────────────────────┐
       ▼                      ▼
┌─ State Update ─┐    ┌─ Effects Queue ──┐
│ store.setState │    │ [FOCUS, SCROLL]  │
└────────────────┘    └──────────────────┘


┌─ onAfter(action, state) ────────┐
└─────────────────────────────────┘


┌─ React Render ─┐
│ Selectors      │
│ notify         │
└────────────────┘


┌─ DOM Commit ───┐◄─── Effects Queue
└────────────────┘


┌─ Effect Execution (rAF) ────────┐
│ onEffect(e)? true → skip default│
│ else: focus/scroll/clipboard    │
└─────────────────────────────────┘


┌─ Userland Callbacks ────────────┐
│ onCommit, onPaste, onDelete     │
└─────────────────────────────────┘

Released under the MIT License.