Row selection
Selection is a state slice — a list of ids living in the pgTable store, which is why it survives sort, filter, and pagination changes without per-component state. Toggle into it from row checkboxes, union into it from "select all", read it from your bulk-action handlers.
Moderate-tier setup
A grid with selection is moderate-tier: a config that declares the selectedIds slice, and a component that owns usePgTable and renders. Selection isn't tied to <table> chrome — the cards example renders the same selection pattern as a grid of <button aria-pressed> cards.
// table-config — selection lives in state, so it survives sort/filter. import z from 'zod' import { type PgTableRow, pgt } from '@westopp/pgtable' 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 type Character = PgTableRow<typeof charactersColumns> export const charactersState = pgt.tableState( { selectedIds: z.array(z.string()) }, { selectedIds: [] }, )
Per-row toggle
A row's checkbox subscribes to selectedIds and flips its membership in one immer touch — written through tableApi.setState. tableApi.useWatchState('selectedIds') re-renders the row when the array changes.
type Props = { tableApi: PgTableApi<typeof charactersColumns, typeof charactersState> rowId: string } export const RowCheckbox = ({ tableApi, rowId }: Props) => { const selectedIds = tableApi.useWatchState('selectedIds') const checked = selectedIds.includes(rowId) const onToggle = () => { tableApi.setState((prev) => { const i = prev.selectedIds.indexOf(rowId) if (i === -1) prev.selectedIds.push(rowId) else prev.selectedIds.splice(i, 1) }) } return <input type="checkbox" checked={checked} onChange={onToggle} /> }
Tri-state header
The header checkbox has three states: every row selected (checked), some selected (indeterminate), or none selected (unchecked). indeterminate is set imperatively on the DOM node — there's no React prop for it.
// Header checkbox with tri-state (checked / indeterminate / unchecked). type Props = { tableApi: PgTableApi<typeof charactersColumns, typeof charactersState> } export const SelectAllHeader = ({ tableApi }: Props) => { const rows = tableApi.useWatchData() const selectedIds = tableApi.useWatchState('selectedIds') const allIds = useMemo(() => rows.map((r) => r.id), [rows]) const allSelected = allIds.length > 0 && allIds.every((id) => selectedIds.includes(id)) const someSelected = !allSelected && selectedIds.length > 0 const ref = useRef<HTMLInputElement>(null) useEffect(() => { if (ref.current) ref.current.indeterminate = someSelected }, [someSelected]) const onToggleAll = () => { tableApi.setState((prev) => { prev.selectedIds = allSelected ? [] : allIds.slice() }) } return <input ref={ref} type="checkbox" checked={allSelected} onChange={onToggleAll} /> }
"Select all" usually means visible
Users expect the header checkbox to act on the rows they can see, not on a million-row server-side dataset. Compute the visible ids using the same sort/filter derivation as the rendering component, then union or diff against the current selection so off-page selections survive a filter change.
// "Select all" usually means "all visible rows", not "all rows in the dataset". // Compute visible ids from the same derivation the component uses, then // union/diff against the existing selection so off-screen selections survive. const onSelectAllVisible = (visibleIds: string[]) => { tableApi.setState((prev) => { const next = new Set(prev.selectedIds) for (const id of visibleIds) next.add(id) prev.selectedIds = Array.from(next) }) } const onClearVisible = (visibleIds: string[]) => { const banned = new Set(visibleIds) tableApi.setState((prev) => { prev.selectedIds = prev.selectedIds.filter((id) => !banned.has(id)) }) }
Consume selection in handlers
Bulk actions don't need the subscription — they need the current value. Read selectedIds via tableApi.getState inside the click handler, hit your API, then update data and clear selectedIds in one round.
// Bulk actions consume selection via tableApi.getState — non-reactive. const onDeleteSelected = async () => { const ids = tableApi.getState('selectedIds') if (ids.length === 0) return await api.deleteCharacters(ids) const banned = new Set(ids) tableApi.setData((rows) => rows.filter((r) => !banned.has(r.id))) tableApi.setState({ selectedIds: [] }) }
Selection and persistence
Selection survives unmount in memory/session/local persistence, same as any state slice. If your selection is ephemeral UI (a checkbox state, not a saved preference), clear it on mount or filter it out at restore time so the user doesn't return to a stale checked-rows highlight from a previous session.
// Selection survives unmount when the table persists. If you don't want that // — e.g. selection is per-session UI, not data — clear it on mount. useEffect(() => { tableApi.setState({ selectedIds: [] }) }, []) // Or wire it into the persist config's onBeforeRestore so the restored payload // keeps data but throws away selection. usePgTable(charactersColumns, charactersState, { pgTableId: 'characters', persist: { mode: 'local', onBeforeRestore: (payload) => { ;(payload.state as { selectedIds: string[] }).selectedIds = [] return { shouldRestore: true } }, }, })