pgTable/Guides/Server pagination
Guides~7 min

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.

TSshared/shared-table-configs.ts
// 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(),
})
TScharacters/characters-table-config.ts
// 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.

TScharacters/characters-api.ts
// 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.

TScharacters/characters-table-listeners.ts
// 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.

TScharacters/use-characters-table.ts
// 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.

TSXcharacters/characters-table.tsx
// 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.
}