Skip to content

API Surface & DOM Access

Depends on: Philosophy, State Model

Hook Hierarchy

useGridBehavior(options)          Main hook, complete setup

       │ returns grid instance

┌──────┴──────────────────────────┐
│                                 │
├─ useCellBehavior(grid, ...)     Reactive cell state + props
├─ useHeaderBehavior(grid, ...)   Reactive header state + props
├─ useRowBehavior(grid, ...)      Reactive row state + props

├─ useGridSelector(grid, sel)     Subscribe to global state
└─ useCellSelector(grid, cell)    Subscribe to cell state, O(1)

useGridBehavior

Main hook. Creates store, manages transitions, executes effects.

Input

OptionTypeDescription
rows{ id: string }[]Row objects in visual order (e.g., TanStack rows)
columns{ id: string }[]Column objects in visual order (e.g., TanStack columns)
isEditable(cell) => booleanCan cell enter edit mode
configGridConfigKeybindings, a11y settings
initialStatePartial<GridState>Override initial state (optional)

Lifecycle Hooks (Input)

HookSignaturePurpose
onBefore(action) => booleanBlock action if false
onAfter(action, state) => voidReact post-transition
onEffect(effect) => booleanIntercept effect, true = handled

Callbacks (Input)

CallbackSignatureWhen
onCommit(cell, value, original) => voidEdit committed
onPaste(startCell, data) => voidPaste processed
onDelete(cells) => voidDelete requested
onSelectionChange(ranges) => voidSelection changed

Output

PropertyTypeDescription
storeGridStoreZustand store with dispatch and getContext
registriesRegistriesNon-reactive registries (cell types, refs)
propsGridPropsSpreadable props for grid container

GridStore methods:

MethodTypeDescription
(selector)TSubscribe to state with Zustand
dispatch(action) => voidTriggers state transition
getContext() => GridContextGet current context (derived rowIds, colIds, config)
getState() => GridStateGet current state snapshot

Props Getters

getGridProps()

PropValuePurpose
refcallbackRegistry grid element
role'grid'ARIA
aria-rowcountrowCountARIA
aria-colcountcolCountARIA
onKeyDownhandlerGlobal navigation

getCellProps(rowId, colId)

Returns static props only. For reactive state (tabIndex, aria-selected), use useCellBehavior.

PropValuePurpose
refcallbackRegistry cell element
role'gridcell'ARIA
data-row-idrowIdDebug, CSS
data-col-idcolIdDebug, CSS
onClickhandlerFocus, select
onDoubleClickhandlerEdit
onKeyDownhandlerCell shortcuts
onFocushandlerFocus sync

getHeaderProps(colId)

Returns static props only. For reactive state (tabIndex), use useHeaderBehavior.

PropValuePurpose
refcallbackRegistry header element
role'columnheader'ARIA
data-col-idcolIdDebug, CSS
onClickhandlerFocus header
onKeyDownhandlerKeyboard navigation

getRowProps(rowId)

PropValuePurpose
role'row'ARIA
aria-rowindexindex + 1ARIA (1-based)

Selectors

useGridSelector(grid, selector)

Subscribe to global state. Re-renders when selector result changes (shallow equal).

Typical useSelector
In edit mode?s => s.focus.mode === 'edit'
In interactive mode?s => s.focus.mode === 'interactive'
Selection counts => s.selection.ranges.length
Focus modes => s.focus.mode
Current drafts => s.draft
Has focus?s => s.focus.target !== null

useCellSelector(grid, cellRef, selector)

Subscribe to state relative to single cell. Internal registry maps cellKey → subscribers. Focus change notifies only involved cells (old/new), O(1).

Typical useSelector
Is focused?'focused' or c => c.focused
Is selected?'selected' or c => c.selected
Is editing?'editing' or c => c.editing
Is in interactive?'interactive' or c => c.interactive

String selectors are shorthand for common cases.

Cell state object:

typescript
type CellState = {
  focused: boolean; // focus.target points to this cell
  selected: boolean; // cell is in selection.ranges
  editing: boolean; // focused AND mode === 'edit'
  interactive: boolean; // focused AND mode === 'interactive'
};

Behavior Hooks (Reactive Mode)

Form library style hooks that return spreadable props and reactive state.

useCellBehavior(grid, rowId, colId, options?)

Returns everything needed for a cell: props, state, and (for composite cells) interactive element registration.

Cell Types:

TypeOptionDescriptionReturns
Display(none)Read-only, no special interactionDisplayCellBehavior
Editable{ type: 'editable' }Enter/F2/typing enters edit modeEditableCellBehavior
Composite{ type: 'composite' }Tab enters interactive mode for internal focusablesCompositeCellBehavior
tsx
// Display cell (default)
const cell = useCellBehavior(grid, rowId, colId);

// Editable cell
const cell = useCellBehavior(grid, rowId, colId, { type: "editable" });

// Composite cell (has focusable children)
const cell = useCellBehavior(grid, rowId, colId, { type: "composite" });

<div
  {...cell.props} // Spread all props
  className={cn(
    cell.isFocused && "ring-2",
    cell.isSelected && "bg-blue-50",
    cell.isBorderTop && "border-t-2",
  )}
>
  {/* Composite cells use interactive() to register focusables */}
  <button {...cell.interactive("save")}>Save</button>
  <button {...cell.interactive("delete")}>Delete</button>
</div>;

Returns:

PropertyTypeDescription
propsCellBehaviorPropsSpreadable props (ref, role, tabIndex, aria-*, handlers)
isFocusedbooleanCell has focus
isSelectedbooleanCell is in selection
isEditingbooleanCell is being edited
isInteractivebooleanCell is in interactive mode
isBorderTop/Bottom/Left/RightbooleanSelection border edges
interactive(id, options?)() => InteractivePropsRegister interactive focusables

interactive() for interactive mode:

tsx
// Register internal focusable elements
<button {...cell.interactive('edit')}>Edit</button>
<button {...cell.interactive('delete', { tabIndex: 0 })}>Delete</button>

Elements registered with interactive():

  • Get tabIndex: -1 in navigation mode (not tabbable)
  • Get specified tabIndex (default: 0) in interactive mode

useHeaderBehavior(grid, colId)

Same pattern for headers.

tsx
const header = useHeaderBehavior(grid, colId);

<div {...header.props} className={cn(header.isFocused && "ring-2")}>
  {children}
  <button {...header.interactive("sort")}>Sort</button>
</div>;

useRowBehavior(grid, rowId)

Provides row props. Unlike useCellBehavior/useHeaderBehavior, this hook does NOT subscribe to the Zustand store. Rows are layout containers — their props are static and derive only from context. This prevents unnecessary re-renders when focus/selection changes.

tsx
const row = useRowBehavior(grid, rowId);

<div {...row.props}>{cells}</div>;

Returns:

PropertyTypeDescription
propsRowBehaviorPropsSpreadable props (role, aria-rowindex)

DOM Access

Refs Registry

typescript
Registry = {
  grid: HTMLElement | null
  cells: Map<`${rowId}:${colId}`, HTMLElement>
  headers: Map<colId, HTMLElement>
  liveRegion: HTMLElement | null
}

Population

Automatic via ref callbacks in props getters:

  • Mount: ref callback receives element → inserted in Map
  • Unmount: ref callback receives null → removed from Map

Lookup (for effects)

MethodReturn
refs.getCell(rowId, colId)HTMLElement | null
refs.getHeader(colId)HTMLElement | null
refs.getGrid()HTMLElement | null
refs.getLiveRegion()HTMLElement | null

Virtualization

Cells outside viewport: unmounted → removed from registry. Effects on cells not in registry: no-op. Scroll effect must bring cell into view before focus.

Intercept SCROLL_INTO_VIEW to use virtualizer:

typescript
onEffect: (effect) => {
  if (effect.type === "SCROLL_INTO_VIEW") {
    virtualizer.scrollToIndex(getRowIndex(effect.cell.rowId));
    return true;
  }
  return false;
};

ARIA Live Region

Rendered internally (hidden), used for screen reader announcements.

html
<div aria-live="polite" aria-atomic="true" style="visually-hidden" />

ANNOUNCE effect updates textContent. 50ms timeout to ensure screen reader picks it up.

Internal Store (Zustand)

Why Zustand

ProCon
Granular selectors out-of-boxAdditional dependency
No Context provider (performance)Less familiar API than useState
O(1) subscriptions per cell
Middleware ecosystem (devtools, persist)

Alternatives considered:

  • useState + Context: cascade re-render on every change
  • useReducer + Context: same problem
  • Valtio: proxy magic can confuse, less control
  • Jotai: good but atoms pattern less suited to structured state

Architecture

useGridBehavior

    ├─► creates Zustand store (internal state)

    ├─► useGridSelector uses store.subscribe + selector

    └─► useCellSelector uses store.subscribe + cell-specific selector
            with internal registry for O(1) notify

Store is source of truth. Userland reads via selectors, modifies via dispatch.

Extensibility

Lifecycle Hooks

Generic, operate on any action/effect.

HookSignatureMomentReturn
onBefore(action) => booleanPre-transitionfalse = cancel
onAfter(action, newState) => voidPost-transition
onEffect(effect) => booleanPre-effect executiontrue = handled, skip default

Why Generic (not specific)

ApproachProCon
Generic (onBefore)1 hook for everything, automatically extensibleUserland switches on action.type
Specific (onBeforeEdit, onBeforeDelete, ...)Type-safe, autocompleteN hooks to implement/maintain

Choice: generic. Trivial implementation, new actions automatically supported. Type narrowing on action.type still gives type safety.

Flow with Hooks

dispatch(action)


onBefore(action) ──false──► STOP
    │ true

transition(state, action, ctx) → { state, effects }


setState(newState)


onAfter(action, newState)


for each effect:
    onEffect(effect) ──true──► skip default
        │ false

    executeDefault(effect)

Config

OptionTypeDefaultDescription
keyBindingsPartial<KeyBindings>defaultsOverride shortcuts
a11y.announceNavigationbooleantrueAnnounce focus changes
a11y.announceSelectionbooleantrueAnnounce selection changes
editing.commitOnBlurbooleantrueCommit when focus leaves cell
editing.commitOnEnterbooleantrueCommit on Enter
selection.mode'single' | 'range''range'Selection mode

Default KeyBindings

ActionDefaultConfigurable
NavigateArrow keysYes
Select extendShift + ArrowYes
Select allCtrl/Cmd + AYes
EditEnter, F2Yes
CommitEnterYes
CancelEscapeYes
CopyCtrl/Cmd + CYes
PasteCtrl/Cmd + VYes
DeleteDelete, BackspaceYes

Event Composition

composeEventHandlers

Utility for composing userland handlers with library handlers. Follows Radix UI pattern.

typescript
composeEventHandlers<E extends SyntheticEvent>(
  originalHandler: ((event: E) => void) | undefined,
  userHandler: ((event: E) => void) | undefined,
  options?: { checkForDefaultPrevented?: boolean }
): (event: E) => void

Behavior:

  1. Original handler (library) runs first
  2. If original handler calls preventDefault(), user handler is skipped
  3. Otherwise, user handler runs

Usage:

typescript
import { composeEventHandlers } from "@ts-zen/react-datagrid";

const headerProps = grid.getHeaderProps(colId);

<div
  {...headerProps}
  onClick={composeEventHandlers(
    headerProps.onClick,
    (e) => {
      // Add custom behavior: select column on header click
      grid.store.dispatch({ type: "SELECT_COLUMN", colId });
    }
  )}
/>

When to use:

  • Adding behavior to props without losing library functionality
  • Extending header/cell click handlers
  • Composing keyboard handlers

Virtualization

Hook knows nothing about virtualization. Support via pattern, not implementation.

Without Virtualization

  • All cells in DOM
  • SCROLL_INTO_VIEW uses element.scrollIntoView({ block: 'nearest' })
  • Browser handles everything

With Virtualization

Cells outside viewport don't exist in DOM. Userland intercepts effect:

typescript
onEffect: (effect) => {
  if (effect.type === "SCROLL_INTO_VIEW") {
    const rowIndex = rowIds.indexOf(effect.cell.rowId);
    virtualizer.scrollToIndex(rowIndex, { align: "auto" });
    return true; // handled
  }
  return false;
};

Focus on Virtualized Cells

ScenarioBehavior
Focus on cell in viewportFOCUS_ELEMENT finds ref, focus ok
Focus on cell outside viewportRef null, focus silently ignored
With scroll interceptUserland scrolls first, focus after render

Complex timing (scroll → render → focus) is userland's responsibility. Intentional approach: every virtualizer has different API.


Complete ARIA Reference

Roles

ElementRoleNotes
Grid containerrole="grid"Required
Row elementrole="row"Required
Data cellrole="gridcell"Required
Column headerrole="columnheader"Required for headers
Row headerrole="rowheader"Optional, v1 out of scope

Properties and States

AttributeElementWhen SetValue
aria-labelGridAlways (or labelledby)Descriptive label
aria-labelledbyGridWhen external labelID of labeling element
aria-describedbyGridWhen description existsID of description element
aria-rowcountGridWhen virtualizedTotal row count
aria-colcountGridWhen virtualizedTotal column count
aria-multiselectableGridWhen range selection"true"
aria-rowindexRowWhen virtualized1-based row position
aria-colindexCellWhen virtualized1-based column position
aria-selectedCellWhen selection enabled"true" or "false"
aria-readonlyCellWhen editing enabled"true" or "false"
aria-invalidCellDuring validation error"true"
aria-sortHeaderWhen column sortable"ascending", "descending", "none"
aria-colspanCellWhen cell spans columnsNumber (non-table grids)
aria-rowspanCellWhen cell spans rowsNumber (non-table grids)
aria-busyGrid/CellDuring async operation"true" (userland sets)

tabindex Pattern (Roving)

Element StatetabindexPurpose
Focused cell0In tab sequence
Other cells-1Navigable via arrow keys
Grid (no focus)0Grid enters tab sequence
Grid (has focus)-1Focus is on cell, not grid

Virtualization ARIA

When rows/columns are virtualized, userland must set indices:

typescript
// Row element
<div role="row" aria-rowindex={actualRowIndex + 1}>

// Cell element
<div role="gridcell" aria-colindex={actualColIndex + 1}>

Important: Indices are 1-based per ARIA spec.


Announcements

Default Announcement Messages

The library announces state changes via ARIA live region.

EventDefault MessageConfigurable
Focus cell"{content}", row {n} of {total}, column {name}"Yes
Focus header"Column {name}"Yes
Enter edit mode"Editing"Yes
Exit edit mode (commit)"Saved"Yes
Exit edit mode (cancel)"Cancelled"Yes
Selection (single)"Selected"Yes
Selection (range)"{n} cells selected"Yes
Selection cleared"Selection cleared"Yes
Validation error"Error: {message}"Yes
Copy"{n} cells copied"Yes
Paste"Pasted"Yes

Configuration

typescript
useGridBehavior({
  // ...
  config: {
    a11y: {
      announceNavigation: true, // Announce focus changes
      announceSelection: true, // Announce selection changes
      announceEditing: true, // Announce edit mode changes
      messages: {
        focusCell: (cell, content) => `${content}, row ${cell.rowIndex}`,
        enterEdit: () => "Editing cell",
        // ... custom message overrides
      },
    },
  },
});

Throttling

Rapid announcements (e.g., holding arrow key) are throttled to prevent overwhelming the screen reader:

ScenarioBehavior
Single navigationAnnounce immediately
Rapid navigationAnnounce only final position
Debounce delay150ms (configurable)
Critical announcementsNever throttled (errors, commits)

Released under the MIT License.