pgTable/Guides/Row selection
Guides~6 min

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.

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

TSXcharacters/characters-row-checkbox.tsx
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.

TSXcharacters/characters-select-all.tsx
// 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.

TScharacters/select-visible.ts
// "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.

TScharacters/bulk-actions.ts
// 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.

TSXcharacters/reset-on-mount.ts
// 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 }
  },
},
})