pgTable/Guides/For AI agents
Guides~6 min

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.ts and 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.

TSXbooks-table.tsx
// 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.

TSproducts/products-table-config.ts
// 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 },
)
TSXproducts/products-table.tsx
// 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.

TSinvoices/invoices-table-listeners.ts
// 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() }
TSinvoices/use-invoices-table.ts
// 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 */ }
}
TSXinvoices/invoices-table.tsx
// 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 has tableApi in scope (recommended); the destructurable useWatchData / useWatchState from the usePgTable return when reading in the outer; the standalone useWatchData(id) / useWatchState(id, key) imports when you only have a pgTableId string.
  • Writes go through the APIsetData / setState (or their global equivalents). Never mutate the array returned by a watch hook or the object returned by tableApi.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.
  • pgTableId is the only identity — caller-assigned, stable, never Math.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.ts once 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.

llms.txtserved at /llms.txt
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.

fetch
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:

rules / prompt snippet
# 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.
vendor it
# 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.