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.
// 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.
// 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.
// 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.
// 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.
// '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) }, })