pgTable/Concepts/Reactivity
Concepts~5 min

Reactivity

Per-slice subscriptions. Three hooks, two non-reactive reads, and a listener channel for side effects. The mental model is short — the payoff is that you can compose ten subscribers in one table without thinking about memoisation.

The subscription surface

There are three reactive hooks and two non-reactive reads. The hooks subscribe; the reads don't. That's the whole reactivity surface.

TSsurface.ts
// Render body — useWatch* subscribes. Handlers / effects — tableApi.get* reads
// without subscribing. The two are not interchangeable. Inside a component
// that has tableApi in scope, the scoped methods are the natural shape —
// pre-bound to pgTableId, pre-typed against the columns and state shape.

// One key — re-renders only when that slice changes.
const sort = tableApi.useWatchState('sort')

// No key — re-renders when any state key changes.
const all = tableApi.useWatchState()

// Data — re-renders when the rows change.
const rows = tableApi.useWatchData()

// Non-reactive — fresh value, no subscription. Use inside handlers / effects.
const current = tableApi.getState('sort')
const data    = tableApi.getData()

Per-slice granularity in practice

Each child of your inner can pick its own subscription. There's no provider tree, no useMemo dance, no selector library — every key in your state declaration is a subscription channel. Pass the typed tableApi down and call the scoped methods directly.

TSXcharacters-table.tsx
// Each child of the inner picks its own subscription against tableApi.
// Pagination only re-renders on pagination changes.
const Pager = ({ tableApi }: Props) => {
const { page, pageSize, total } = tableApi.useWatchState('pagination')
return <Buttons page={page} pageSize={pageSize} total={total} />
}

// SortDropdown only re-renders on sort changes.
const SortDropdown = ({ tableApi }: Props) => {
const sort = tableApi.useWatchState('sort')
return <Select value={sort.by} />
}

// Grid only re-renders on data changes — sort / pagination changes pass it by.
const Grid = ({ tableApi }: Props) => {
const rows = tableApi.useWatchData()
return <Rows rows={rows} />
}

Hooks are hooks — keep them at the top

useWatchState and useWatchData obey the Rules of Hooks. Call them at the top level of your component, never inside a conditional or a loop. Subscriptions are wired on the first render and refreshed when the key argument changes; a conditional call breaks the call order and silently corrupts the subscription set.

TSXhooks-rule.tsx
// BAD — conditional hook call.
const Cell = ({ tableApi, live }) => {
if (live) {
  const data = tableApi.useWatchData()                    // ← breaks Rules of Hooks
  return <Live rows={data} />
}
return <Frozen />
}

// GOOD — hoist the hook, branch on the value.
const Cell = ({ tableApi, live }) => {
const data = tableApi.useWatchData()
return live ? <Live rows={data} /> : <Frozen />
}

Non-reactive reads for handlers

Event handlers and async callbacks don't want subscriptions — they want the current value. Reach for tableApi.getState / tableApi.getData there. These are plain function calls; they don't tie the surrounding component to anything.

TShandlers.ts
// Inside an event handler — read via tableApi.getState, not useWatchState.
const onSortClick = (key: string) => {
const { direction } = tableApi.getState('sort')
tableApi.setState((s) => {
  s.sort.by = key
  s.sort.direction = direction === 'asc' ? 'desc' : 'asc'
})
}

// Inside an async callback — same thing. Reads through getData / getState; writes through setData / setState.
const onSave = async () => {
if (!tableApi.isDirtyState()) return
await api.save(tableApi.dirtyStateProperties())
}

State listeners and onStateChange

Subscribers re-render React. State listeners run side effects. They're registered through usePgTable's options and called after each committed write — per-key via stateListeners.<key>, or catch-all via onStateChange. Use state listeners for fetches, analytics, URL syncing, anything you'd otherwise dump into a useEffect that watches the state.

TSuse-characters-table.ts
// 'tableApi' is captured from the destructure so listener bodies can write
// back into the same store they're observing. Non-trivial listener bodies
// should be lifted to named-table-state-listeners.
const { tableApi } = usePgTable(charactersColumns, charactersState, {
pgTableId: 'docs:concepts:characters',
// Per-key — fires when that slice changes, after the commit.
// Args: (newSliceValue, prevFullState, getCurrentData)
stateListeners: {
  sort: async (next, _prevState, currentData) => {
    const rows = await api.fetch({ sort: next, exclude: currentData().map((r) => r.id) })
    tableApi.setData(rows)
  },
  selectedIds: (ids) => {
    analytics.track('selection_changed', { count: ids.length })
  },
},

// Catch-all — fires once per commit, gets the full changed-keys list.
onStateChange: ({ propertiesUpdated, state }) => {
  console.debug('state changed:', propertiesUpdated, state)
},
})