pgTable/Why pgTable
Overview~5 min

Why pgTable

Six things pgTable gives you that you would otherwise stitch together by hand. Ordered from the most distinguishing to the least.

State ownership
Not state plumbing
One immer-backed store per table.
Sort, filter, pagination, selection, loading, errors — declared once.
State survives unmount; mount it again and it comes back.

Most table libraries hand you primitives and let you wire the state. pgTable inverts that. You declare your state slices with pgt.tableState, and the store owns them from there. The table reads from the store; your widgets write to it; nothing else has to coordinate. Forget the row of useState calls, the reducers, and the context wrappers — the store is the wiring.

Persistence
Built in
mode: 'session' | 'local' — pick a surface.
Schema versioning with onPersistSchemaMismatch migration hooks.
Drafts and dashboards survive refresh without a separate layer.

Persistence is a persist option on usePgTable, not a plugin. Set mode: 'local' and the table writes itself to localStorage on every change; set mode: 'session' and it lives for the tab. Bump schemaVersion when your shape changes and you migrate inside the callback. No middleware, no separate store, no useEffect dance.

Imperative access
From anywhere
getTable(id) / setTableData(id, …) work outside React.
Event handlers, async callbacks, route loaders — same store, every time.
No prop drilling and no provider trees.

Every table is reachable by its pgTableId. A toast handler can read the current selection; a router loader can prime the data; one table can react to another. The React hook is the convenient surface, but the store is the source of truth — and it doesn't care whether you're inside a component or not.

Independently reactive
Data ≠ state
tableApi.useWatchData() for the rows.
tableApi.useWatchState(key) for a single state slice.
A sort dropdown re-renders on sort, not on row changes.

Data and state are tracked separately, and state is tracked per key. The pagination component subscribes to pagination. The sort dropdown subscribes to sort. The row grid subscribes to data. None of them re-render when the others change. You get the granularity of a hand-tuned store without writing one.

Typed columns
From a runtime schema
Column types come from z.ZodType.
One declaration covers runtime parsing and compile-time inference.
Your own metadata shape rides along on every column.

pgt.tableColumns<Meta>()(cols) takes a Zod type per column plus your meta shape — labels, widths, cell renderers, anything you like. TypeScript infers the row type from the schemas, and parsing/validation come along for the ride. No duplicate definitions, no type drift between runtime and compile-time.

Dirty tracking
And aggregation
isDirtyData(), isDirtyState(), dirtyStateProperties() — public API.
Multiple tables compose into a single payload via the global store.
No lifting, no context, no external store.

Save-on-dirty is a one-liner. Multi-table forms — a dashboard with three editable grids feeding one submit — work because every table is reachable from a single helper. Walk the global store, collect dirty slices, ship the payload. No Zustand wrapper, no Redux slice, no React Context provider tree.