Sorting and filtering
Sort and filter are two more state slices. Where they live matters more than what they hold: client-side, the component derives the visible rows from tableApi.useWatchData() + tableApi.useWatchState(key) through a useMemo; server-side, state-listeners turn slice changes into requests. The control surface is the same either way.
Moderate-tier config
Client-side sort and filter don't need a table-hook — there's no orchestration to crowd the render. The moderate-table tier splits the table-config from the table-component: shape lives in <name>-table-config.ts, the component owns usePgTable and renders.
// table-config — declared once via pgt.tableState. import z from 'zod' import { type PgTableRow, pgt } from '@westopp/pgtable' export type CharactersColumnMeta = { label: string; sortable: boolean } export const charactersColumns = pgt.tableColumns<CharactersColumnMeta>()({ id: { type: z.string(), label: 'ID', sortable: false }, name: { type: z.string(), label: 'Name', sortable: true }, role: { type: z.string(), label: 'Role', sortable: true }, active: { type: z.boolean(), label: 'Active', sortable: false }, }) export type Character = PgTableRow<typeof charactersColumns> export const charactersState = pgt.tableState( { sort: z.object({ by: z.string().nullable(), direction: z.enum(['asc', 'desc']) }), filters: z.object({ query: z.string(), roles: z.array(z.string()), activeOnly: z.boolean() }), }, { sort: { by: 'name', direction: 'asc' }, filters: { query: '', roles: [], activeOnly: false }, }, )
Client-side: derive in the composition-component
If the dataset fits in memory, sort and filter are pure functions of data + state. Subscribe with the watch hooks, derive the visible list inside useMemo, and hand it down to a render-component. The memo recomputes when rows or either slice changes — not on every render.
// Composition-component. Owns usePgTable, subscribes via watch // hooks, derives the visible list, hands it to the render-component. import { useMemo } from 'react' import { type PgTableApi, usePgTable } from '@westopp/pgtable' import { type Character, charactersColumns, charactersState } from './characters-table-config' type InnerProps = { tableApi: PgTableApi<typeof charactersColumns, typeof charactersState> } const CharactersInner = ({ tableApi }: InnerProps) => { const rows = tableApi.useWatchData() const sort = tableApi.useWatchState('sort') const filters = tableApi.useWatchState('filters') const visible = useMemo(() => { const filtered = rows.filter((r) => { if (filters.activeOnly && !r.active) return false if (filters.roles.length && !filters.roles.includes(r.role)) return false if (filters.query && !r.name.toLowerCase().includes(filters.query.toLowerCase())) return false return true }) if (!sort.by) return filtered const dir = sort.direction === 'asc' ? 1 : -1 return [...filtered].sort((a, b) => { const av = a[sort.by as keyof Character] const bv = b[sort.by as keyof Character] return av < bv ? -1 * dir : av > bv ? 1 * dir : 0 }) }, [rows, sort, filters]) // …render header (calls onSort), filter panel (calls onFilters), and visible rows. } export const CharactersTable = () => { const { tableApi } = usePgTable(charactersColumns, charactersState, { pgTableId: 'characters', }) return <CharactersInner tableApi={tableApi} /> }
The sort header
A three-state header (asc → desc → cleared) is one immer updater. Read the current sort slice reactively so the arrow re-renders when the column becomes active or inactive. The write goes through tableApi.setState.
// Click a header to sort. Three-state: asc → desc → cleared. type HeaderProps = { tableApi: PgTableApi<typeof charactersColumns, typeof charactersState> columnKey: 'name' | 'role' label: string } export const SortableHeader = ({ tableApi, columnKey, label }: HeaderProps) => { const sort = tableApi.useWatchState('sort') const isActive = sort.by === columnKey const onSort = () => { tableApi.setState((prev) => { if (!isActive) { prev.sort.by = columnKey prev.sort.direction = 'asc' } else if (prev.sort.direction === 'asc') { prev.sort.direction = 'desc' } else { prev.sort.by = null prev.sort.direction = 'asc' } }) } const arrow = isActive ? (sort.direction === 'asc' ? ' ↑' : ' ↓') : '' return <button onClick={onSort}>{label}{arrow}</button> }
Server-side: promote to a table-hook
For large datasets every slice change is a request, the listeners need a shared refetch, and pagination has to reset on filter changes. That orchestration crowds the component — the right move is to promote to the complex-table tier with named-table-state-listeners. See Server pagination for the full layout; the listener shapes look like the snippet below.
// Named-table-state-listeners for the server-side case. // Multi-key updates and a fetch trigger — well past the trivial threshold. import { getTable } from '@westopp/pgtable' import { CHARACTERS_TABLE_ID, type charactersColumns, type charactersState } from './characters-table-config' export type CharactersListenerCtx = { refetch: () => Promise<void> } const getCharactersApi = () => getTable<typeof charactersColumns, typeof charactersState>(CHARACTERS_TABLE_ID) export const onFiltersChange = (ctx: CharactersListenerCtx) => () => { const api = getCharactersApi() if (!api) return // Reset to page 1 — otherwise the user lands on page 7 of a 3-page filtered result. api.setState((prev) => { prev.pagination.page = 1 }) void ctx.refetch() } export const onSortChange = (ctx: CharactersListenerCtx) => () => { void ctx.refetch() } export const onPaginationChange = (ctx: CharactersListenerCtx) => () => { void ctx.refetch() }
Debounce upstream, not downstream
For a search input, debounce the value before it lands in the store. Writing every keystroke into state.filters.query would fire the listener on every keystroke; debouncing on the input side means the store sees one commit per finished search.
// Debounce on the input side, not in the listener. The store commits exactly // what you write — debounce upstream so the listener fires once per finished // search, not once per keystroke. type Props = { tableApi: PgTableApi<typeof charactersColumns, typeof charactersState> } export const SearchBox = ({ tableApi }: Props) => { const filters = tableApi.useWatchState('filters') const [local, setLocal] = useState(filters.query) useEffect(() => { const id = setTimeout(() => { if (local === tableApi.getState('filters').query) return tableApi.setState((prev) => { prev.filters.query = local }) }, 250) return () => clearTimeout(id) }, [local, tableApi]) return <input value={local} onChange={(e) => setLocal(e.target.value)} /> }