Editing rows
All row mutations go through tableApi.setData. Pass a value to replace, pass an updater to mutate the immer draft. Append, edit, delete, reload — same four-method surface. The store coalesces every touch inside one updater into one commit, so subscribers re-render once no matter how many rows you touched.
Moderate-tier setup
A grid with inline cell editing is moderate-tier: a <name>-table-config.ts declaring columns and state, and a <name>-table.tsx that owns usePgTable and renders. No table-hook is needed because there's no orchestration beyond the writes themselves.
// table-config — columns + state declared once. 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 }, level: { type: z.number(), label: 'Level', sortable: true }, }) export type Character = PgTableRow<typeof charactersColumns> export const charactersState = pgt.tableState({}, {})
The write surface
setData covers everything; appendRows / clearData / resetData are convenience shortcuts that read more clearly than their updater equivalents.
// Whole-array writes — replace, append, clear, reset. tableApi.setData(nextRows) // replace tableApi.appendRows([{ id: 'r4', name: 'New row' }]) // append tableApi.clearData() // [] tableApi.resetData() // back to initialData // Updater form — immer draft, mutate any cell, row, or shape. tableApi.setData((rows) => { rows[index].name = 'Edited' // single cell rows.push(newRow) // append rows.splice(index, 1) // delete })
Edit a single cell
The pattern that matters: look up rows by id, not by index. Sort, filter, and pagination can all rearrange or hide rows from the component's point of view, but the underlying rows array in the store is always the unsorted, unfiltered source. findIndex by id and you're always touching the right row.
// Cell edit committed on blur. Read the row by id, not by index — indexes // shift under sort/filter and pagination. type NameCellProps = { tableApi: PgTableApi<typeof charactersColumns, typeof charactersState> rowId: string initial: string } export const NameCell = ({ tableApi, rowId, initial }: NameCellProps) => { const commit = (e: FocusEvent<HTMLInputElement>) => { const next = e.target.value if (next === initial) return tableApi.setData((rows) => { const i = rows.findIndex((r) => r.id === rowId) if (i !== -1) rows[i].name = next }) } return <input defaultValue={initial} onBlur={commit} /> }
Insert and delete
Insertion at a position, deletion by id, bulk delete by a set — all immer mutations inside one setData call. Filter-based bulk deletes are especially cheap: one commit, one notify, no per-row work.
// Insert at a position (or at the end if you don't care). const insertAfter = (rowId: string, newRow: Character) => { tableApi.setData((rows) => { const i = rows.findIndex((r) => r.id === rowId) if (i === -1) rows.push(newRow) else rows.splice(i + 1, 0, newRow) }) } // Delete by id — safe under any current view. const deleteRow = (rowId: string) => { tableApi.setData((rows) => { const i = rows.findIndex((r) => r.id === rowId) if (i !== -1) rows.splice(i, 1) }) } // Bulk delete — write the filter result back. One commit, one notify. const deleteSelected = (ids: string[]) => { const banned = new Set(ids) tableApi.setData((rows) => rows.filter((r) => !banned.has(r.id))) }
Reloads and full replacements
When fresh rows arrive from a server fetch, hand them straight to setData — no updater needed. The shallow row-equality check still applies, so re-fetching the same data with no changes won't re-render subscribers.
// Replace the whole grid from a fetch. setData accepts a value directly — // no updater needed. const reload = async () => { tableApi.setState({ loading: true }) const rows = await fetchCharacters() tableApi.setData(rows) // resets to the fresh server snapshot tableApi.setState({ loading: false }) }
Dirty tracking and reset
Every write bumps a comparison against initialData. isDirtyData() tells you whether the current rows match the initial snapshot — useful for "unsaved changes" guards, save buttons, and discard prompts. resetData() rolls back to that snapshot.
// isDirtyData() compares the current rows against the initials the table // was mounted with (or the last restored snapshot for persisted tables). if (tableApi.isDirtyData()) { const result = await api.save(tableApi.getData()) if (result.ok) { // Lock the new snapshot in as "not dirty" by remounting the hook with the // fresh data, or just keep going — the next save still works. } } // Reset to the last initial snapshot. tableApi.resetData()