pgTable/Guides/Multiple tables on one page
Guides~5 min

Multiple tables on one page

pgTable's store is keyed by pgTableId, so two tables on the same page are just two entries. Each one is a standalone-table with its own config, its own usePgTable call, no cross-state, no provider tree, no lifting.

Two simple-tier tables, two stable ids

Side-by-side tables don't share state, so each one is its own simple-table: a single <name>-table.tsx with the config inline. The only rule that matters between them is that each carries a stable, caller-assigned pgTableId — never Math.random() per render.

TSXcharacters/characters-table.tsx
// Simple-tier table — config inline, no orchestration, stable pgTableId.
import z from 'zod'
import { type PgTableApi, pgt, usePgTable } from '@westopp/pgtable'

const charactersColumns = pgt.tableColumns<{ label: string; sortable: boolean }>()({
id:   { type: z.string(), label: 'ID',   sortable: false },
name: { type: z.string(), label: 'Name', sortable: true  },
})

const charactersState = pgt.tableState({}, {})

type InnerProps = { tableApi: PgTableApi<typeof charactersColumns, typeof charactersState> }

const CharactersInner = ({ tableApi }: InnerProps) => {
const data = tableApi.useWatchData()
// …render rows
}

export const CharactersTable = () => {
const { tableApi } = usePgTable(charactersColumns, charactersState, {
  pgTableId: 'characters',
  data:      charactersSeed,
})
return <CharactersInner tableApi={tableApi} />
}
TSXitems/items-table.tsx
// Simple-tier table next door — its own config, its own stable pgTableId.
import z from 'zod'
import { type PgTableApi, pgt, usePgTable } from '@westopp/pgtable'

const itemsColumns = pgt.tableColumns<{ label: string; sortable: boolean }>()({
id:   { type: z.string(), label: 'ID',   sortable: false },
name: { type: z.string(), label: 'Name', sortable: true  },
})

const itemsState = pgt.tableState({}, {})

type InnerProps = { tableApi: PgTableApi<typeof itemsColumns, typeof itemsState> }

const ItemsInner = ({ tableApi }: InnerProps) => {
const data = tableApi.useWatchData()
// …render rows
}

export const ItemsTable = () => {
const { tableApi } = usePgTable(itemsColumns, itemsState, {
  pgTableId: 'items',
  data:      itemsSeed,
})
return <ItemsInner tableApi={tableApi} />
}
TSXdashboard/dashboard-page.tsx
// Page composes both. No shared state, no provider tree, no context.
export const DashboardPage = () => (
<div>
  <CharactersTable />
  <ItemsTable />
</div>
)

One save for many tables

The classic multi-table form: a dashboard with several editable grids and one "Save" button. Walk the store by id using global-helpers (they take inputs through params), collect dirty payloads, post once.

TSdashboard/submit-dashboard.ts
// One submit, many tables. The global helpers walk the store by pgTableId.
import { getTable } from '@westopp/pgtable'

export const submitDashboard = async () => {
const ids = ['characters', 'items', 'locations']

const payload = ids.reduce<Record<string, unknown>>((acc, id) => {
  const api = getTable(id)
  if (!api) return acc
  if (api.isDirtyData() || api.isDirtyState()) {
    acc[id] = {
      rows:    api.isDirtyData() ? api.getData() : undefined,
      changes: api.dirtyStateProperties(),
    }
  }
  return acc
}, {})

if (Object.keys(payload).length === 0) return
await api.saveDashboard(payload)
}

Cross-table writes from outside React

A change in one table can drive writes into another via setTableData / setTableState (caller-agnostic, identified by pgTableId). The store is the contract — no prop-drilling, no callback gymnastics.

TSdashboard/refresh-from-org.ts
// Refresh two dependent tables from a single trigger. Uses the global
// helpers (caller-agnostic, identified by pgTableId).
import { setTableData, setTableState } from '@westopp/pgtable'

export const refreshFromOrg = async (org: string) => {
setTableState('characters', { loading: true })
setTableState('items',      { loading: true })

const [characters, items] = await Promise.all([
  api.fetchCharacters({ org }),
  api.fetchItems({ org }),
])

setTableData('characters', characters)
setTableData('items',      items)
setTableState('characters', { loading: false })
setTableState('items',      { loading: false })
}

Same id = same entry

Two usePgTable calls with the same id share the store entry — that's the contract of pgTableId. Useful when a sidebar widget and a main grid read the same table; not useful when you want side-by-side independents. For "compare" views, give the two mounts distinct ids.

TSXdashboard/aliasing.ts
// One table per id at a time. Two components mounting usePgTable with the
// same id reuse the same store entry — the second mount picks up wherever
// the first left off (data, state, state listeners are replaced). That's the
// intended behaviour for "open the same dashboard in two panels", not for
// "render two copies of the same table side by side".

// If you do want side-by-side independent copies, give them different ids.
usePgTable(columns, state, { pgTableId: 'characters:left',  data: rows })
usePgTable(columns, state, { pgTableId: 'characters:right', data: rows })

Cleanup on exit

Tables persist by pgTableId, not by component lifecycle (except for persist: { mode: 'none' }, which clears on unmount). For UI-only tables that don't need to survive, call removeTable(id) on the page's unmount. For app-level reset, dropTableEverywhere(id) wipes memory and webstorage in one call.

TSdashboard/cleanup.ts
// On dashboard exit, drop the entries that aren't meant to persist.
import { dropTableEverywhere, removeTable } from '@westopp/pgtable'

useEffect(() => () => {
// Pure-UI tables (no persistence) can be cleaned up here.
removeTable('characters')
removeTable('items')
}, [])

// Or, for a full wipe (memory + any persisted webstorage entry):
const onResetAll = () => {
dropTableEverywhere('characters')
dropTableEverywhere('items')
}