State Model & Machine
Depends on: Philosophy
State: Structure
Two slices (focus, selection) plus editing state (draft, original).
| Component | Responsibility | Changes when |
|---|---|---|
focus | Which element is active + interaction mode | Arrow keys, click, Tab, Enter, F2, Escape |
selection | Which cells are selected | Click, Shift+Click, Ctrl+A, keyboard with Shift |
draft | Current value during editing | Typing in editor |
original | Value before editing started | Enter edit mode |
Focus
| Field | Type | Description |
|---|---|---|
target | CellRef | HeaderRef | null | Currently focused element |
mode | 'navigation' | 'edit' | 'interactive' | Determines how keyboard input is interpreted |
Focus Modes:
| Mode | Arrow Keys | Enter/F2 | Escape | Use Case |
|---|---|---|---|---|
navigation | Move between cells | Enter edit/interactive | Clear selection | Default grid navigation |
edit | Cursor in input | Commit + exit | Cancel + exit | Inline text editing |
interactive | Interactive-specific | Interactive-specific | Exit to navigation | Complex 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
| Field | Type | Description |
|---|---|---|
ranges | SelectionRange[] | Array of selected ranges |
anchor | CellRef | null | Starting point for Shift extension |
SelectionRange = { start: CellRef, end: CellRef }
v1: single contiguous range. Array structure for future extensibility (multi-range with Ctrl).
Editing State
| Field | Type | Description |
|---|---|---|
draft | unknown | null | Current value in editor (null if not editing) |
original | unknown | null | Value 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:
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.
| Responsibility | Library | Userland |
|---|---|---|
| Enter interactive mode | Dispatches ENTER_WIDGET_MODE | Renders interactive UI |
| Arrow keys | Passes through (no-op) | Handles within interactive |
| Tab key | Exits interactive mode if appropriate | May cycle internal focusables |
| Escape key | Exits to navigation mode | May close popups first |
| Interactive state | — | Manages (dropdown open, selection) |
Pattern for combobox in cell:
// 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.
| Aspect | Behavior |
|---|---|
| Who owns state | Library (internal Zustand store) |
| How userland reads | Selectors: useGridSelector, useCellSelector |
| How userland modifies | dispatch(action) |
| Initialization | Default initial state, override via optional initialState |
Transitions
Model: pure transition function that returns new state + effects.
transition(state, action, context) → { state: State, effects: Effect[] }| Component | Role |
|---|---|
state | Current state (immutable) |
action | Event that triggers change |
context | Read-only external info (grid IDs, config) |
effects | Descriptions of side-effects to execute post-render |
Context
Information that the transition function reads but doesn't modify:
| Field | Source | Use |
|---|---|---|
rowIds | TanStack Table | Array of row IDs in visual order, navigation + bounds |
colIds | TanStack Table | Array of column IDs in visual order, navigation + bounds |
isEditable(cellRef) | Config/column def | Can enter edit mode? |
config | Userland | Options (keyboard bindings, a11y settings) |
Derivation from TanStack:
// useGridBehavior accepts any object with .id property
rows: table.getRowModel().rows, // Row[] - each has .id
columns: table.getVisibleLeafColumns(), // Column[] - each has .idIDs are extracted internally. Navigation uses rowIds.indexOf(currentId) to find index, rowIds[index + 1] for next cell.
Action Catalog
Focus Actions
| Action | Trigger | State Changes |
|---|---|---|
FOCUS_CELL | Click on cell | focus.target → cell |
FOCUS_HEADER | Click on header | focus.target → header |
MOVE_FOCUS | Arrow keys (navigation) | focus.target → adjacent cell |
BLUR_GRID | Focus exits grid | focus.target → null, mode → navigation |
Mode Transition Actions
| Action | Trigger | State Changes |
|---|---|---|
ENTER_EDIT_MODE | Enter, F2, typing | focus.mode → edit, draft/original set |
EXIT_EDIT_MODE | Enter, Tab, Escape, blur | focus.mode → navigation, draft/original → null |
ENTER_WIDGET_MODE | Tab in cell with focusables | focus.mode → interactive |
EXIT_WIDGET_MODE | Escape, Tab out | focus.mode → navigation |
UPDATE_DRAFT | Typing in editor | draft updated |
EXIT_EDIT_MODE variants:
| Variant | Trigger | Effect Generated | Draft Handling |
|---|---|---|---|
commit | Enter, Tab, blur | COMMIT_VALUE | Value persisted |
cancel | Escape | None | Value discarded |
Selection Actions
| Action | Trigger | State Changes |
|---|---|---|
SELECT_CELL | Click | selection.ranges → single cell, anchor set |
EXTEND_SELECTION | Shift+Click, Shift+Arrow | selection.ranges extended from anchor |
SELECT_ALL | Ctrl+A | selection.ranges → all cells |
CLEAR_SELECTION | Escape, click elsewhere | selection.ranges → empty |
Clipboard Actions
| Action | Trigger | Effect Generated |
|---|---|---|
COPY | Ctrl+C | WRITE_CLIPBOARD |
PASTE | Ctrl+V | PASTE_DATA (userland handles) |
DELETE | Delete, Backspace | DELETE_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 │
└──────────────┘| From | To | Trigger | Preconditions |
|---|---|---|---|
navigation | edit | Enter, F2, or printable key | Cell is editable |
navigation | interactive | Tab when cell has focusables | Cell has focusable children |
edit | navigation | Enter (commit), Escape (cancel) | — |
edit | navigation | Tab (commit + move) | Moves focus to next cell |
interactive | navigation | Escape, or Tab on last element | — |
Implicit transitions:
| Scenario | Behavior |
|---|---|
MOVE_FOCUS while mode === edit | Implicit commit, then move |
| Click other cell while editing | Implicit commit, focus new cell |
| Grid loses focus while editing | Commit if commitOnBlur: true |
Effects
Descriptions of side-effects, executed after state update.
Why Effects from Transitions (not reactive)
| Pro | Con |
|---|---|
| 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?").
| Effect | When generated | What it does |
|---|---|---|
FOCUS_ELEMENT | After MOVE_FOCUS, FOCUS_CELL | element.focus() |
SCROLL_INTO_VIEW | After navigation | element.scrollIntoView() or virtualizer.scrollToIndex |
WRITE_CLIPBOARD | After COPY | navigator.clipboard.write() |
ANNOUNCE | After navigation, mode changes (a11y) | Update ARIA live region |
COMMIT_VALUE | After EXIT_EDIT_MODE (commit variant) | Calls userland onCommit callback |
PASTE_DATA | After PASTE | Calls userland onPaste callback with parsed data |
DELETE_VALUES | After DELETE | Calls 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:
- Double-click on cell →
SELECT_CELLqueuesFOCUS_ELEMENTvia RAF - Editor input renders with
autoFocus - RAF fires, but input (child of cell) already has focus
FOCUS_ELEMENTskipselement.focus()to avoid stealing focus from editor
Without this check, focus would steal from the input, triggering blur → cancel edit.
Effect Execution
Sequence:
dispatch(action)calledtransition(state, action, ctx)executed (pure)store.setState(newState)— React schedules re-render- After DOM commit, effects executed in order via
requestAnimationFrame
Userland can intercept:
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.
| Scenario | Behavior |
|---|---|
onCommit throws | Error bubbles up. Library doesn't catch. Userland should try/catch in callback. |
onPaste throws | Same — error bubbles up |
onDelete throws | Same — error bubbles up |
isEditable throws | Treated as false (cell not editable). Error logged to console. |
| Clipboard API unavailable | Silent no-op. Effect completes without action. |
| Effect execution throws | Error 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:
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)
| Hook | Scope | Notification complexity |
|---|---|---|
useGridSelector(grid, selector) | Global state | O(listeners) on every change |
useCellSelector(grid, cellRef, selector) | Single cell | O(1) — internal map cellKey → subscribers |
useCellSelector necessary for performance: 1000 cells, focus changes → 2 notifications (old/new), not 1000 recalculations.
Lifecycle Hooks (sync interception)
| Hook | Signature | Purpose |
|---|---|---|
onBefore | (action) => boolean | Block any action if false |
onAfter | (action, state) => void | React to any completed action |
Async pattern: hook returns false, userland does async check, then manually dispatches if ok.
Callbacks (post-transition side-effects)
| Callback | When | Payload |
|---|---|---|
onCommit | Edit committed | { cell, value, original } |
onPaste | Paste detected | { startCell, data: string[][] } |
onDelete | Delete requested | { cells: CellRef[] } |
onSelectionChange | Selection changes | { ranges } |
Fire-and-forget, don't block transitions.
Performance Considerations
| Problem | Solution |
|---|---|
| Cascade re-render on focus change | Granular selectors: useFocusedCell() only for those who need it |
| Many cells, few selected | Selection check O(1): internal Set, not range iteration |
| Virtualization | Stable IDs, not indices. SCROLL_INTO_VIEW effect interceptable |
| Frequent draft updates during edit | Draft isolated, only editor component subscribes to draft |
| Mode changes | Mode is part of focus, minimal re-renders via selectors |
Invariants
Conditions always true, transitions maintain them:
- Edit mode requires focus: If
focus.mode === 'edit', thenfocus.targetis aCellRef(not null, not header) - Edit state consistency: If
focus.mode === 'edit', thendraft !== null && original !== null - Navigation mode has no draft: If
focus.mode === 'navigation', thendraft === null && original === null - Interactive mode requires focus: If
focus.mode === 'interactive', thenfocus.target !== null - Anchor in range:
selection.anchoris always inside one ofselection.ranges(or null if ranges empty) - Valid IDs: IDs in state (
rowId,colId) exist in current context (or transition invalidates them) - Headers not editable: If
focus.targetis aHeaderRef, thenfocus.modecannot 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 │
└─────────────────────────────────┘