pgTable/Guides/Dirty tracking and save
Guides~6 min

Dirty tracking and save

pgTable compares the current rows and each state slice against the values you mounted with. Three methods read that comparison — isDirtyData, isDirtyState, dirtyStateProperties — and that's all you need to wire a save button, a partial payload, an unsaved-changes guard, and a discard action.

Moderate-tier setup

An editable grid with a save button is moderate-tier: a <name>-table-config.ts declaring the shape (including initial rows the table is "rebased" against), and a <name>-table.tsx that owns usePgTable and renders.

TScharacters/characters-table-config.ts
// table-config — initial values are the baseline isDirty* compares against.
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(
{
  filters: z.object({ query: z.string() }),
  sort:    z.object({ by: z.string().nullable(), direction: z.enum(['asc', 'desc']) }),
},
{
  filters: { query: '' },
  sort:    { by: 'name', direction: 'asc' },
},
)

The dirty-tracking surface

Dirty checks are calculated on call — no flag to manage, no useEffect to keep in sync. Compare to the initials, return the answer.

TScharacters/dirty-surface.ts
tableApi.isDirtyData()              // boolean — current data ≠ initialData?
tableApi.isDirtyState()             // boolean — any state slice changed?
tableApi.dirtyStateProperties()     // Partial<state> — just the changed slices

tableApi.resetData()                // roll back to initialData
tableApi.resetState()               // roll back to initialState

A save button that subscribes

isDirtyData / isDirtyState aren't reactive on their own — they're plain function calls (imperative reads). To re-render when dirtiness changes, subscribe to data and to all state slices using the watch hooks, then call the dirty checks at render time. tableApi.useWatchState() (no key) subscribes to every slice in one call.

TSXcharacters/characters-save-button.tsx
// A save button that activates when either side is dirty.
type Props = { tableApi: PgTableApi<typeof charactersColumns, typeof charactersState> }

export const SaveButton = ({ tableApi }: Props) => {
// Subscribe to data + all state slices so the button re-renders when either
// changes. useWatchState with no key subscribes to the whole state.
tableApi.useWatchData()
tableApi.useWatchState()

const dirty = tableApi.isDirtyData() || tableApi.isDirtyState()

const onSave = async () => {
  if (!dirty) return
  await api.save({
    rows:    tableApi.getData(),
    changes: tableApi.dirtyStateProperties(),
  })
  // After a successful save, see "Rebasing after save" below.
}

return <button disabled={!dirty} onClick={onSave}>Save</button>
}

A tight save payload

dirtyStateProperties() returns only the slices that have changed from the initial values — a Partial of your state shape. Use it as the body of a PATCH request, or merge it into a larger payload. There's no equivalent for data (rows are coarse; either you send all of them or you implement row-level dirty tracking yourself).

TScharacters/partial-save.ts
// dirtyStateProperties returns only the slices that diverge from initialState.
// Use it to send a tight payload — your server doesn't need every key.
const payload = tableApi.dirtyStateProperties()
// → { filters: { query: 'mira' }, sort: { by: 'name', direction: 'desc' } }
// Slices that match the initial values are omitted.

Unsaved-changes guard

A beforeunload listener that watches dirtiness gets you the browser's "you have unsaved changes" prompt. For Tanstack Router or React Router guards, use the same dirty signal — both libraries expose a "block navigation if predicate is true" hook.

TScharacters/use-unsaved-guard.ts
// Block navigation while the table has unsaved edits. Subscribe so the
// effect re-runs when dirtiness changes.
export const useUnsavedGuard = (tableApi: PgTableApi<typeof charactersColumns, typeof charactersState>) => {
tableApi.useWatchData()
tableApi.useWatchState()

const dirty = tableApi.isDirtyData() || tableApi.isDirtyState()

useEffect(() => {
  if (!dirty) return
  const handler = (e: BeforeUnloadEvent) => {
    e.preventDefault()
    e.returnValue = ''
  }
  window.addEventListener('beforeunload', handler)
  return () => window.removeEventListener('beforeunload', handler)
}, [dirty])
}

Rebasing after save

After a successful save, the current values are the new "saved" state — but pgTable's initials haven't moved. The cleanest re-baseline is to remount the hook with the freshly-saved snapshot. Bump a key on the outer component and pass the new initials in. The store entry is replaced and dirtiness flips back to false.

TSXcharacters/characters-page.tsx
// "Lock in" the current values as the new baseline after save. The cleanest
// way: remount the hook with fresh initials. Bump a key on the outer.
const CharactersPage = () => {
const [version, setVersion] = useState(0)

return (
  <CharactersTable
    key={version}                                       // remount = re-baseline
    onSaved={() => setVersion((v) => v + 1)}
  />
)
}

// Inside CharactersTable — pass the freshly-saved snapshot as initials.
export const CharactersTable = ({ onSaved }: { onSaved: () => void }) => {
const { tableApi } = usePgTable(charactersColumns, charactersState, {
  pgTableId: 'characters',
  data:      snapshotFromServer,
})
// …render
}

Persistence changes the baseline

Restored payloads overwrite both current values and initials. That's deliberate — without it, a refresh would always come back marked dirty (current ≠ originally-declared initial). It does mean "reset to declared initials" requires dropTableEverywhere followed by a remount.

TScharacters/discard-everything.ts
// Persisted tables: the initials are overwritten by the restored snapshot.
// That means a freshly-restored table mounts as NOT dirty — the user's last
// session's edits become the new baseline. If you want the original declared
// initials back, remount with persist disabled or call dropTableEverywhere.
import { dropTableEverywhere } from '@westopp/pgtable'

const onDiscardEverything = () => {
dropTableEverywhere('characters')                     // wipe memory + webstorage
// Force a remount so the table comes back with declared initials.
}