For AI agents
This page is the rulebook an LLM should read before generating pgTable code. The conventions here are stable, named, and machine-checkable. The lower half is llms.txt, served at /llms.txt, which compresses the same surface into a single fetchable file.
Pick the right tier
pgTable consumers build tables in one of three file-structure patterns. Pick the lowest tier that fits the table you're generating, and promote when (and only when) the higher tier's reason actually applies.
- simple-table — single file, table-config inline. Pick this when the table is read-mostly, no orchestration, no shared config, and would fit comfortably in one file.
- moderate-table — config split from component, no table-hook. Pick this when the table-config deserves to be importable on its own (tests, another page, declutter), but orchestration is still small enough to live inline in the component.
- complex-table — config + table-hook + component, with optional
<name>-table-listeners.tsand shared registries. Pick this when there is a fetch-hook, when state-listeners re-fetch on slice changes, when registries are involved, or when curated helpers are needed for the component.
Default to simple-table. Promote on cause, not on speculation.
Canonical file names
Names and placement are not optional — a reviewer should find the config without grep:
<name>-table.tsx— the table-component (always present).<name>-table-config.ts— columns + state shape + initial values (moderate and complex).use-<name>-table.ts— the table-hook with the curated API (complex only).<name>-table-listeners.ts— named-table-state-listeners (complex only, only when extraction is warranted).<name>-api.ts— plain-function transport layer next to the table (complex, when fetching).shared/shared-table-configs.ts— state-registry + column-registry shared across tables.
Simple-tier snippet
One file, config inline, no orchestration. Suitable for a settings page, a small admin tool, a dashboard widget.
// simple-table: table-config inline, single file. import { useMemo } from 'react' import z from 'zod' import { type PgTableApi, pgt, usePgTable } from '@westopp/pgtable' const columns = pgt.tableColumns<{ label: string; sortable: boolean }>()({ title: { type: z.string(), label: 'Title', sortable: true }, author: { type: z.string(), label: 'Author', sortable: true }, }) const state = pgt.tableState( { sort: z.object({ by: z.string().nullable(), direction: z.enum(['asc', 'desc']) }) }, { sort: { by: 'title', direction: 'asc' } }, ) type InnerProps = { tableApi: PgTableApi<typeof columns, typeof state> } const BooksInner = ({ tableApi }: InnerProps) => { // Render-body reads: the scoped useWatch* on tableApi — pre-bound to // pgTableId, pre-typed against columns/state. No id arg, no generics. const data = tableApi.useWatchData() const sort = tableApi.useWatchState('sort') // …derive sorted list, render headers + rows, click handlers call tableApi.setState } export const BooksTable = () => { const { tableApi } = usePgTable(columns, state, { pgTableId: 'books', data: booksSeed }) return <BooksInner tableApi={tableApi} /> }
Moderate-tier snippet
Two files in a per-table subdirectory. The config exists as its own artifact — importable from a test or another page; the component owns usePgTable and renders.
// moderate-table — table-config in its own file. import z from 'zod' import { pgt } from '@westopp/pgtable' export type ProductsColumnMeta = { label: string; sortable: boolean } export const productsColumns = pgt.tableColumns<ProductsColumnMeta>()({ name: { type: z.string(), label: 'Name', sortable: true }, price: { type: z.number(), label: 'Price', sortable: true }, }) export const productsState = pgt.tableState( { query: z.string(), page: z.number() }, { query: '', page: 1 }, )
// moderate-table — component imports the config, owns usePgTable, renders. import { usePgTable } from '@westopp/pgtable' import { productsColumns, productsState } from './products-table-config' export const ProductsTable = () => { const { tableApi } = usePgTable(productsColumns, productsState, { pgTableId: 'products', data: productsSeed, persist: { mode: 'session', schemaVersion: 1 }, }) // …subscribe via tableApi.useWatchData() / tableApi.useWatchState(key), // write via tableApi.setState }
Complex-tier snippet
Three files in the table's subdirectory, plus <name>-api.ts and optional <name>-table-listeners.ts. Use this layout the moment there is a fetch-hook or non-trivial state-listeners.
// Named-table-state-listeners — extracted because each one // resets paging and triggers a fetch. Caller-agnostic: ctx arrives as a param. import { getTable } from '@westopp/pgtable' import { INVOICES_TABLE_ID, type invoicesColumns, type invoicesState } from './invoices-table-config' export type InvoicesListenerCtx = { refetch: () => Promise<void> } const getInvoicesApi = () => getTable<typeof invoicesColumns, typeof invoicesState>(INVOICES_TABLE_ID) export const onSortChange = (ctx: InvoicesListenerCtx) => () => { const api = getInvoicesApi() if (!api) return api.setState((prev) => { prev.pagination.page = 1; prev.selectedIds = [] }) void ctx.refetch() } export const onPaginationChange = (ctx: InvoicesListenerCtx) => () => { void ctx.refetch() }
// Table-hook. Owns usePgTable, registers listeners by reference, // wires the transport-layer fetch, exposes a curated API. Consumers reach for // nextPage / sortBy / refetch — not tableApi.setState directly. import { useCallback, useMemo, useRef } from 'react' import { type PgTableApi, usePgTable } from '@westopp/pgtable' import { INVOICES_TABLE_ID, invoicesColumns, invoicesState } from './invoices-table-config' import { onPaginationChange, onSortChange } from './invoices-table-listeners' import { fetchInvoices } from './invoices-api' type InvoicesTableApi = PgTableApi<typeof invoicesColumns, typeof invoicesState> export const useInvoicesTable = () => { const refetchRef = useRef<() => Promise<void>>(async () => {}) const listenerCtx = useMemo(() => ({ refetch: () => refetchRef.current() }), []) const { tableApi } = usePgTable(invoicesColumns, invoicesState, { pgTableId: INVOICES_TABLE_ID, stateListeners: { sort: onSortChange(listenerCtx), pagination: onPaginationChange(listenerCtx), }, }) const refetch = useCallback(async () => { /* …setState loading, fetch, setData */ }, [tableApi]) refetchRef.current = refetch // …nextPage / prevPage / sortBy (each call tableApi.setState internally) return { tableApi, refetch /*, nextPage, prevPage, sortBy */ } }
// Composition-component. Subscribes via the scoped watch hooks on tableApi; // writes through the curated API. Doesn't reach into tableApi.setState directly. import { useEffect, useRef } from 'react' import { useInvoicesTable } from './use-invoices-table' export const InvoicesTable = () => { const { tableApi, refetch, nextPage } = useInvoicesTable() const data = tableApi.useWatchData() const pagination = tableApi.useWatchState('pagination') const didMount = useRef(false) useEffect(() => { if (didMount.current) return; didMount.current = true; void refetch() }, [refetch]) // …render toolbar + rows + pager (pager calls nextPage) }
Listener-extraction threshold
State-listeners stay inline only when they are trivial — a single derived setState, a one-line reset, an obvious side effect. Anything with branching, multi-key updates, fetching, or validation is non-trivial and must be extracted as a named function (in the component file, the table-hook file, or — once volume justifies — a sibling <name>-table-listeners.ts). The test is whether reading the listener inline forces the reader to switch mental gears from "what is this table configured to do" to "what does this handler implement". If yes, extract.
Curated-API rule
A complex-tier component must not call tableApi.setData(...) or tableApi.setState(...) to drive features the table-hook is responsible for (paging, sorting, selection, fetching). Those go through the curated helpers the hook exposes. If a helper is missing, add it to the table-hook — do not reach past the curated layer. True escape hatches still exist (calling usePgTable directly, using global-helpers), and they read as "I am deliberately bypassing the orchestration."
Other invariants worth knowing
- Reactive vs. imperative reads — watch hooks belong in the render body;
tableApi.getData()/tableApi.getState(...)belong in handlers, effects, and async callbacks. Don't mix the two. Watch hooks have three access paths (all subscribe the same store):tableApi.useWatchData()/tableApi.useWatchState(key)when the inner hastableApiin scope (recommended); the destructurableuseWatchData/useWatchStatefrom theusePgTablereturn when reading in the outer; the standaloneuseWatchData(id)/useWatchState(id, key)imports when you only have apgTableIdstring. - Writes go through the API —
setData/setState(or their global equivalents). Never mutate the array returned by a watch hook or the object returned bytableApi.getState(...). - pgTable does not fetch — consumers do. The consumer's React touch of the network is always through a fetch-hook, even on first use.
pgTableIdis the only identity — caller-assigned, stable, neverMath.random()per render.- South African English — in code, comments, and prose (
behaviour,serialise,cancelled,colour). External identifiers (onChange,Authorization,localStorage) keep their original spelling. - Shared shapes via registries — common state-key shapes (sort, pagination, selectedIds) and common columns (id, createdAt, updatedAt) live in
shared/shared-table-configs.tsonce two tables use them; not before.
llms.txt — the file in full
The raw llms.txt as it's served from this site. Hit copy to paste it into your AI tool's project rules or a vendored file.
Loading /llms.txt…
Where it lives
The canonical file is served at /llms.txt from this site. It follows the llmstxt.org convention — a single Markdown file with a one-paragraph summary, the project's load-bearing invariants, and grouped links into the deeper docs.
curl https://pgtable.westopp.com/llms.txt
Pointing an AI tool at it
Most editor-side AI tools accept either a URL or a local file as project-scoped context. Two patterns work:
# pgTable context When working with @westopp/pgtable, treat https://pgtable.westopp.com/llms.txt as authoritative. Fetch it before answering questions about the API surface or recommending a pattern. Follow links in it for deeper detail.
# If your tool prefers a local file, drop a copy into your repo: mkdir -p .ai && curl -fsSL https://pgtable.westopp.com/llms.txt -o .ai/pgtable.llms.txt
What to expect (and what not to)
The file is hand-maintained, not generated. It tracks the public surface in @westopp/pgtable's index.ts and the published docs pages on this site — internal modules (the store implementation, the low-level persistence helpers, createTableApi) aren't listed, by design.
If your assistant invents an API that isn't in llms.txt and isn't on the linked pages, treat that as a hallucination. The library doesn't have hidden surface area: if it's not exported from @westopp/pgtable, it's not part of the contract.