Server pagination
A paginated, server-driven table is the canonical complex-table: a config that declares state, a transport-layer function that owns the request shape, a table-hook that owns orchestration and exposes a curated API, and a composition-component that subscribes through watch hooks. The render layer never sees the page change — it only sees rows.
1. Declare the pagination slice from the state-registry
Pagination is a state slice with the same shape on every server-driven table — page, pageSize, total. It belongs in a state-registry and is referenced from the table-config. Each consuming config keeps its own initial values, because those are instance-specific.
// state-registry — shared shapes referenced from multiple table-configs. import z from 'zod' import { pgt } from '@westopp/pgtable' export const sharedStateRegistry = pgt.stateRegistry({ sort: z.object({ by: z.string().nullable(), direction: z.enum(['asc', 'desc']) }), pagination: z.object({ page: z.number(), pageSize: z.number(), total: z.number() }), loading: z.boolean(), fetchError: z.string().nullable(), })
// table-config — declarative only. Columns and state // shape live here; orchestration lives in the table-hook next door. import z from 'zod' import { pgt } from '@westopp/pgtable' import { sharedStateRegistry } from '../shared/shared-table-configs' 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 }, }) export const charactersState = pgt.tableState( { sort: sharedStateRegistry.sort, pagination: sharedStateRegistry.pagination, loading: sharedStateRegistry.loading, fetchError: sharedStateRegistry.fetchError, }, { sort: { by: 'name', direction: 'asc' }, pagination: { page: 1, pageSize: 25, total: 0 }, loading: false, fetchError: null, }, ) export const CHARACTERS_TABLE_ID = 'characters'
2. Wrap the transport in a plain function
The HTTP call lives in a *-api.ts next to the table — a plain function the fetch-hook wraps. Keeping the transport layer caller-agnostic means it takes its params and returns its result; it does not reach into a sibling form for the query.
// Plain-function transport layer. The fetch-hook wraps this. import type { PgTableData } from '@westopp/pgtable' import type { charactersColumns } from './characters-table-config' export type FetchCharactersParams = { page: number pageSize: number sortBy: string | null sortDirection: 'asc' | 'desc' signal: AbortSignal } export type FetchCharactersResponse = { rows: PgTableData<typeof charactersColumns>; total: number } export const fetchCharacters = async (params: FetchCharactersParams): Promise<FetchCharactersResponse> => { const res = await fetch(`/api/characters?page=${params.page}&pageSize=${params.pageSize}`, { signal: params.signal }) return (await res.json()) as FetchCharactersResponse }
3. Extract the listeners that re-fetch
The listeners on sort and pagination each reset paging where needed and trigger a fresh fetch. That's branching, multi-key state writes, and an async side effect — well past the context-switch threshold, so they live in their own <name>-table-listeners.ts and are passed by reference into stateListeners. They take the orchestration handle as a param, not via a global import — caller-agnostic by design.
// Named-table-state-listeners. The orchestration handle arrives // as a param so the listeners stay caller-agnostic. 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 onSortChange = (ctx: CharactersListenerCtx) => () => { const api = getCharactersApi() if (!api) return api.setState((prev) => { prev.pagination.page = 1 }) void ctx.refetch() } export const onPaginationChange = (ctx: CharactersListenerCtx) => () => { void ctx.refetch() }
4. The table-hook: usePgTable, listeners, curated API
The table-hook owns usePgTable, registers listeners by reference, wires the fetch through the transport layer, and exposes a curated API — nextPage, prevPage, sortBy, refetch. The full tableApi is not part of the contract; consumers reach for the curated helpers instead.
// Table-hook. Owns the usePgTable instance, registers the // listeners, fetches through the transport layer, and exposes a curated API. import { useCallback, useMemo, useRef } from 'react' import { type PgTableApi, usePgTable } from '@westopp/pgtable' import { CHARACTERS_TABLE_ID, charactersColumns, charactersState } from './characters-table-config' import { onPaginationChange, onSortChange } from './characters-table-listeners' import { fetchCharacters } from './characters-api' type CharactersTableApi = PgTableApi<typeof charactersColumns, typeof charactersState> export type UseCharactersTable = { tableApi: CharactersTableApi refetch: () => Promise<void> nextPage: () => void prevPage: () => void sortBy: (key: 'name' | 'role') => void } export const useCharactersTable = (): UseCharactersTable => { // Listeners need refetch, but listeners are registered before refetch exists. // A ref keeps the latest closure reachable without resubscribing. const refetchRef = useRef<() => Promise<void>>(async () => {}) const inflightRef = useRef<AbortController | null>(null) const listenerCtx = useMemo(() => ({ refetch: () => refetchRef.current() }), []) const { tableApi } = usePgTable(charactersColumns, charactersState, { pgTableId: CHARACTERS_TABLE_ID, stateListeners: { sort: onSortChange(listenerCtx), pagination: onPaginationChange(listenerCtx), }, }) const refetch = useCallback(async () => { inflightRef.current?.abort() const ctrl = new AbortController() inflightRef.current = ctrl tableApi.setState({ loading: true, fetchError: null }) try { const { page, pageSize } = tableApi.getState('pagination') const sort = tableApi.getState('sort') const response = await fetchCharacters({ page, pageSize, sortBy: sort.by, sortDirection: sort.direction, signal: ctrl.signal }) if (ctrl !== inflightRef.current) return // cancelled — newer request in flight tableApi.setData(response.rows) tableApi.setState((prev) => { prev.pagination.total = response.total; prev.loading = false }) } catch (err) { if ((err as Error).name === 'AbortError') return tableApi.setState({ loading: false, fetchError: err instanceof Error ? err.message : 'Unknown error' }) } }, [tableApi]) refetchRef.current = refetch // …curated helpers: nextPage / prevPage / sortBy — all setState through tableApi. return { tableApi, refetch /*, nextPage, prevPage, sortBy */ } as UseCharactersTable }
5. The composition-component subscribes through watch hooks
The component calls the table-hook, subscribes to the slices it actually renders against, and routes every write through the curated API. No useWatchState inside click handlers, no tableApi.setState({...}) from the component — that's the table-hook's job.
// Composition-component. Subscribes via the scoped watch hooks on tableApi, // writes through the curated API exposed by the table-hook. import { useEffect, useRef } from 'react' import { useCharactersTable } from './use-characters-table' export const CharactersTable = () => { const { tableApi, refetch, nextPage, prevPage } = useCharactersTable() const data = tableApi.useWatchData() const pagination = tableApi.useWatchState('pagination') const loading = tableApi.useWatchState('loading') // Initial fetch on mount. Subsequent fetches are triggered by listeners. const didMount = useRef(false) useEffect(() => { if (didMount.current) return didMount.current = true void refetch() }, [refetch]) const pageCount = Math.max(1, Math.ceil(pagination.total / pagination.pageSize)) // …render the grid from `data`, render a pager that calls nextPage / prevPage. }