Docs
Persistence
Persistence
Turn grid edits into create / update / delete change-sets with useGridChanges.
The engine is uncontrolled. Opt into useGridChanges to watch edits and get a precise CRUD delta: plain React, separate install.
All changes saved
| Name | Email | Role |
|---|
1"use client"2
3/**4 * niko-table/grid — staging save with `useGridChanges`.5 *6 * The engine stays uncontrolled; `useGridChanges` observes it and turns edits7 * into a CRUD change-set. Edit a few cells, add a row, delete a row — the8 * "Save" button reports exactly what changed, "persists" it (simulated), and9 * reconciles: succeeded rows go clean; incomplete creates (missing Name or10 * Email) stay dirty and highlight via `failedRowIds`.11 */12import { Badge } from "@/components/ui/badge"13import { Button } from "@/components/ui/button"14import { DataTableRowContextMenuSlot } from "@/components/niko-table/components/data-table-row-context-menu-slot"15import { DataTable } from "@/components/niko-table/core/data-table"16import { DataTableColumnResize } from "@/components/niko-table/components/data-table-column-resize"17import { DataTableRoot } from "@/components/niko-table/core/data-table-root"18import {19 DataTableVirtualizedBody,20 DataTableVirtualizedHeader,21} from "@/components/niko-table/core/data-table-virtualized-structure"22import type { CellEditorProps } from "@/components/niko-table/grid/cells/cell-props"23import { GridTextCell } from "@/components/niko-table/grid/cells/grid-text-cell"24import { DataGridClipboard } from "@/components/niko-table/grid/components/grid-clipboard"25import { DataGridFillHandle } from "@/components/niko-table/grid/components/grid-fill-handle"26import { GridRowMenu } from "@/components/niko-table/grid/components/grid-row-menu"27import {28 DataGridAddRows,29 DataGridToolbar,30} from "@/components/niko-table/grid/components/grid-toolbar"31import { DataGrid } from "@/components/niko-table/grid/core/data-grid"32import { DataGridCell } from "@/components/niko-table/grid/core/data-grid-context"33import { useDataGrid } from "@/components/niko-table/grid/hooks/use-data-grid"34import { useGridChanges } from "@/components/niko-table/grid/hooks/use-grid-changes"35import type {36 CellState,37 GridRow,38} from "@/components/niko-table/grid/types/grid-cell"39import type { DataTableColumnDef } from "@/components/niko-table/types"40import { CircleAlert, Loader2, Save } from "lucide-react"41import * as React from "react"42
43const COLUMN_IDS = ["name", "email", "role"] as const44const LABELS: Record<string, string> = {45 name: "Name",46 email: "Email",47 role: "Role",48}49
50const resolveCell = (_columnId: string, raw: string): CellState<string> => ({51 raw,52 value: raw || null,53 status: raw === "" ? "empty" : "valid",54})55const cell = (raw: string): CellState<string> => resolveCell("", raw)56
57// Two "server" rows (they have stable ids) — editing them is an UPDATE.58const INITIAL_ROWS: GridRow[] = [59 {60 id: "u1",61 name: cell("Bailey Chen"),6263 role: cell("Admin"),64 },65 {66 id: "u2",67 name: cell("Noah Park"),6869 role: cell("Editor"),70 },71]72
73const createEmptyRow = (id: string): GridRow => ({ id })74
75// Compare rows by their raw cell values so editing a cell back to its original76// value drops out of `getChangeSet().updated` (a real revert, not a change).77const rowsEqual = (a: GridRow, b: GridRow) =>78 COLUMN_IDS.every(id => {79 const av = a[id]80 const bv = b[id]81 const ar = typeof av === "object" && av != null ? av.raw : ""82 const br = typeof bv === "object" && bv != null ? bv.raw : ""83 return ar === br84 })85
86const gridCellClassName = () =>87 "border-border border-r p-0 align-middle last:border-r-0"88
89type SaveState =90 | { phase: "idle" }91 | { phase: "saving" }92 | {93 phase: "saved"94 createdOk: number95 updated: number96 deleted: number97 failed: number98 /** Why failed creates were rejected (demo "server" validation). */99 failReason?: string100 }101
102/** Demo server rule: a create needs Name + Email. Updates/deletes always ok. */103function isCompleteCreate(row: GridRow): boolean {104 const name = row.name105 const email = row.email106 const nameRaw =107 typeof name === "object" && name != null ? name.raw.trim() : ""108 const emailRaw =109 typeof email === "object" && email != null ? email.raw.trim() : ""110 return nameRaw.length > 0 && emailRaw.length > 0111}112
113export function GridSave() {114 const grid = useDataGrid<GridRow>({115 columnIds: COLUMN_IDS,116 createEmptyRow,117 initialRows: INITIAL_ROWS,118 })119 const changes = useGridChanges(grid, {120 initialRows: INITIAL_ROWS,121 isEqual: rowsEqual,122 })123
124 const [save, setSave] = React.useState<SaveState>({ phase: "idle" })125
126 const onSave = React.useCallback(async () => {127 const { created, updated, deleted } = changes.getChangeSet()128 setSave({ phase: "saving" })129
130 // Pretend to POST. Reject incomplete creates so reconcile + failedRowIds131 // are visible — not "always fail the first create" (that looked like a bug).132 await new Promise(r => setTimeout(r, 700))133 const rejected = created.filter(r => !isCompleteCreate(r))134 const failedIds = rejected.map(r => r.id)135 const succeededIds = [136 ...created.filter(r => !failedIds.includes(r.id)).map(r => r.id),137 ...updated.map(r => r.id),138 ...deleted,139 ]140
141 changes.reconcile({ succeededIds, failedIds })142 setSave({143 phase: "saved",144 createdOk: succeededIds.filter(id => created.some(r => r.id === id))145 .length,146 updated: updated.length,147 deleted: deleted.length,148 failed: failedIds.length,149 failReason:150 failedIds.length > 0151 ? "New rows need a Name and Email before they can be saved."152 : undefined,153 })154 }, [changes])155
156 const columns = React.useMemo<DataTableColumnDef<GridRow>[]>(157 () =>158 COLUMN_IDS.map(id => ({159 id,160 accessorFn: (row: GridRow) => {161 const v = row[id]162 return typeof v === "object" && v != null ? v.raw : ""163 },164 header: LABELS[id],165 size: 220,166 meta: { label: LABELS[id] },167 cell: ctx => (168 <DataGridCell row={ctx.row.original} columnId={id}>169 {(p: CellEditorProps) => (170 <GridTextCell171 {...p}172 resolve={raw => resolveCell(id, raw)}173 placeholder={LABELS[id]}174 />175 )}176 </DataGridCell>177 ),178 })),179 [],180 )181
182 return (183 <DataTableRoot data={grid.rows} columns={columns} getRowId={r => r.id}>184 <DataGrid grid={grid} className="space-y-2 outline-none">185 {/* Flex-fill + drag-resizable columns */}186 <DataTableColumnResize />187 <DataGridClipboard resolveCell={resolveCell} />188 <DataGridFillHandle />189 <DataGridToolbar>190 <DataGridAddRows count={1} />191 <div className="ms-auto flex items-center gap-3">192 {changes.isDirty ? (193 <Badge variant="secondary">194 {changes.dirtyRowIds.size} unsaved195 {changes.dirtyRowIds.size === 1 ? " change" : " changes"}196 </Badge>197 ) : (198 <span className="text-sm text-muted-foreground">199 All changes saved200 </span>201 )}202 <Button203 size="sm"204 onClick={onSave}205 disabled={!changes.isDirty || save.phase === "saving"}206 >207 {save.phase === "saving" ? (208 <Loader2 className="size-4 animate-spin" />209 ) : (210 <Save className="size-4" />211 )}212 Save213 </Button>214 </div>215 </DataGridToolbar>216
217 {save.phase === "saved" ? (218 <p219 role="status"220 className={221 save.failed > 0222 ? "flex items-start gap-1.5 px-1 text-sm text-destructive"223 : "flex items-center gap-1.5 px-1 text-sm text-muted-foreground"224 }225 >226 {save.failed > 0 && (227 <CircleAlert className="mt-0.5 size-4 shrink-0" />228 )}229 <span>230 Saved {save.createdOk} new, {save.updated} updated, {save.deleted}{" "}231 removed.232 {save.failed > 0 &&233 ` ${save.failed} row${save.failed === 1 ? "" : "s"} stayed unsaved. ${save.failReason}`}234 </span>235 </p>236 ) : null}237
238 <DataTable maxHeight={320}>239 <DataTableVirtualizedHeader />240 <DataTableVirtualizedBody<GridRow>241 estimateSize={37}242 fixedRowHeight243 getCellClassName={gridCellClassName}244 getRowClassName={row =>245 changes.failedRowIds.has(row.id) ? "bg-destructive/10" : undefined246 }247 >248 <DataTableRowContextMenuSlot>249 <GridRowMenu />250 </DataTableRowContextMenuSlot>251 </DataTableVirtualizedBody>252 </DataTable>253 </DataGrid>254 </DataTableRoot>255 )256}Preview with Controlled State
All changes saved
| Name | Email | Role |
|---|
Current Grid State
Live view of the grid engine state for demonstration
Focused Cell:None
Editing Cell:None
Selection Anchor:None
Total Rows:2
Is Dirty:No
Dirty Row Ids:0
Failed Row Ids:0
Can Undo / Redo:No / No
Last Commit:None
1"use client"2
3/**4 * niko-table/grid — staging save with a live state panel for docs.5 */6import { Badge } from "@/components/ui/badge"7import { Button } from "@/components/ui/button"8import {9 Card,10 CardAction,11 CardContent,12 CardDescription,13 CardHeader,14 CardTitle,15} from "@/components/ui/card"16import {17 Collapsible,18 CollapsibleContent,19 CollapsibleTrigger,20} from "@/components/ui/collapsible"21import { DataTableRowContextMenuSlot } from "@/components/niko-table/components/data-table-row-context-menu-slot"22import { DataTable } from "@/components/niko-table/core/data-table"23import { DataTableColumnResize } from "@/components/niko-table/components/data-table-column-resize"24import { DataTableRoot } from "@/components/niko-table/core/data-table-root"25import {26 DataTableVirtualizedBody,27 DataTableVirtualizedHeader,28} from "@/components/niko-table/core/data-table-virtualized-structure"29import type { CellEditorProps } from "@/components/niko-table/grid/cells/cell-props"30import { GridTextCell } from "@/components/niko-table/grid/cells/grid-text-cell"31import { DataGridClipboard } from "@/components/niko-table/grid/components/grid-clipboard"32import { DataGridFillHandle } from "@/components/niko-table/grid/components/grid-fill-handle"33import { GridRowMenu } from "@/components/niko-table/grid/components/grid-row-menu"34import {35 DataGridAddRows,36 DataGridToolbar,37} from "@/components/niko-table/grid/components/grid-toolbar"38import { DataGrid } from "@/components/niko-table/grid/core/data-grid"39import { DataGridCell } from "@/components/niko-table/grid/core/data-grid-context"40import { useDataGrid } from "@/components/niko-table/grid/hooks/use-data-grid"41import { useGridChanges } from "@/components/niko-table/grid/hooks/use-grid-changes"42import type {43 CellState,44 GridRow,45} from "@/components/niko-table/grid/types/grid-cell"46import type { DataTableColumnDef } from "@/components/niko-table/types"47import { ChevronRight, CircleAlert, Loader2, Save } from "lucide-react"48import * as React from "react"49
50const COLUMN_IDS = ["name", "email", "role"] as const51const LABELS: Record<string, string> = {52 name: "Name",53 email: "Email",54 role: "Role",55}56
57const resolveCell = (_columnId: string, raw: string): CellState<string> => ({58 raw,59 value: raw || null,60 status: raw === "" ? "empty" : "valid",61})62const cell = (raw: string): CellState<string> => resolveCell("", raw)63
64const INITIAL_ROWS: GridRow[] = [65 {66 id: "u1",67 name: cell("Bailey Chen"),6869 role: cell("Admin"),70 },71 {72 id: "u2",73 name: cell("Noah Park"),7475 role: cell("Editor"),76 },77]78
79const createEmptyRow = (id: string): GridRow => ({ id })80
81const rowsEqual = (a: GridRow, b: GridRow) =>82 COLUMN_IDS.every(id => {83 const av = a[id]84 const bv = b[id]85 const ar = typeof av === "object" && av != null ? av.raw : ""86 const br = typeof bv === "object" && bv != null ? bv.raw : ""87 return ar === br88 })89
90const gridCellClassName = () =>91 "border-border border-r p-0 align-middle last:border-r-0"92
93function formatPos(94 pos: { rowId: string; columnId: string } | null | undefined,95): string {96 if (!pos) return "None"97 return `${pos.rowId} / ${pos.columnId}`98}99
100type SaveState =101 | { phase: "idle" }102 | { phase: "saving" }103 | {104 phase: "saved"105 createdOk: number106 updated: number107 deleted: number108 failed: number109 failReason?: string110 }111
112/** Demo server rule: a create needs Name + Email. */113function isCompleteCreate(row: GridRow): boolean {114 const name = row.name115 const email = row.email116 const nameRaw =117 typeof name === "object" && name != null ? name.raw.trim() : ""118 const emailRaw =119 typeof email === "object" && email != null ? email.raw.trim() : ""120 return nameRaw.length > 0 && emailRaw.length > 0121}122
123export function GridSaveState() {124 const [resetKey, setResetKey] = React.useState(0)125 return (126 <GridSaveStateInner127 key={resetKey}128 onReset={() => setResetKey(k => k + 1)}129 />130 )131}132
133function GridSaveStateInner({ onReset }: { onReset: () => void }) {134 const grid = useDataGrid<GridRow>({135 columnIds: COLUMN_IDS,136 createEmptyRow,137 initialRows: INITIAL_ROWS,138 })139 const changes = useGridChanges(grid, {140 initialRows: INITIAL_ROWS,141 isEqual: rowsEqual,142 })143
144 const [save, setSave] = React.useState<SaveState>({ phase: "idle" })145
146 const onSave = React.useCallback(async () => {147 const { created, updated, deleted } = changes.getChangeSet()148 setSave({ phase: "saving" })149
150 await new Promise(r => setTimeout(r, 700))151 const rejected = created.filter(r => !isCompleteCreate(r))152 const failedIds = rejected.map(r => r.id)153 const succeededIds = [154 ...created.filter(r => !failedIds.includes(r.id)).map(r => r.id),155 ...updated.map(r => r.id),156 ...deleted,157 ]158
159 changes.reconcile({ succeededIds, failedIds })160 setSave({161 phase: "saved",162 createdOk: succeededIds.filter(id => created.some(r => r.id === id))163 .length,164 updated: updated.length,165 deleted: deleted.length,166 failed: failedIds.length,167 failReason:168 failedIds.length > 0169 ? "New rows need a Name and Email before they can be saved."170 : undefined,171 })172 }, [changes])173
174 const columns = React.useMemo<DataTableColumnDef<GridRow>[]>(175 () =>176 COLUMN_IDS.map(id => ({177 id,178 accessorFn: (row: GridRow) => {179 const v = row[id]180 return typeof v === "object" && v != null ? v.raw : ""181 },182 header: LABELS[id],183 size: 220,184 meta: { label: LABELS[id] },185 cell: ctx => (186 <DataGridCell row={ctx.row.original} columnId={id}>187 {(p: CellEditorProps) => (188 <GridTextCell189 {...p}190 resolve={raw => resolveCell(id, raw)}191 placeholder={LABELS[id]}192 />193 )}194 </DataGridCell>195 ),196 })),197 [],198 )199
200 return (201 <div className="w-full space-y-4">202 <DataTableRoot data={grid.rows} columns={columns} getRowId={r => r.id}>203 <DataGrid grid={grid} className="space-y-2 outline-none">204 {/* Flex-fill + drag-resizable columns */}205 <DataTableColumnResize />206 <DataGridClipboard resolveCell={resolveCell} />207 <DataGridFillHandle />208 <DataGridToolbar>209 <DataGridAddRows count={1} />210 <div className="ms-auto flex items-center gap-3">211 {changes.isDirty ? (212 <Badge variant="secondary">213 {changes.dirtyRowIds.size} unsaved214 {changes.dirtyRowIds.size === 1 ? " change" : " changes"}215 </Badge>216 ) : (217 <span className="text-sm text-muted-foreground">218 All changes saved219 </span>220 )}221 <Button222 size="sm"223 onClick={onSave}224 disabled={!changes.isDirty || save.phase === "saving"}225 >226 {save.phase === "saving" ? (227 <Loader2 className="size-4 animate-spin" />228 ) : (229 <Save className="size-4" />230 )}231 Save232 </Button>233 </div>234 </DataGridToolbar>235
236 {save.phase === "saved" ? (237 <p238 role="status"239 className={240 save.failed > 0241 ? "flex items-start gap-1.5 px-1 text-sm text-destructive"242 : "flex items-center gap-1.5 px-1 text-sm text-muted-foreground"243 }244 >245 {save.failed > 0 && (246 <CircleAlert className="mt-0.5 size-4 shrink-0" />247 )}248 <span>249 Saved {save.createdOk} new, {save.updated} updated,{" "}250 {save.deleted} removed.251 {save.failed > 0 &&252 ` ${save.failed} row${save.failed === 1 ? "" : "s"} stayed unsaved. ${save.failReason}`}253 </span>254 </p>255 ) : null}256
257 <DataTable maxHeight={320}>258 <DataTableVirtualizedHeader />259 <DataTableVirtualizedBody<GridRow>260 estimateSize={37}261 fixedRowHeight262 getCellClassName={gridCellClassName}263 getRowClassName={row =>264 changes.failedRowIds.has(row.id)265 ? "bg-destructive/10"266 : undefined267 }268 >269 <DataTableRowContextMenuSlot>270 <GridRowMenu />271 </DataTableRowContextMenuSlot>272 </DataTableVirtualizedBody>273 </DataTable>274 </DataGrid>275 </DataTableRoot>276
277 <Card>278 <CardHeader>279 <CardTitle>Current Grid State</CardTitle>280 <CardDescription>281 Live view of the grid engine state for demonstration282 </CardDescription>283 <CardAction>284 <Button variant="outline" size="sm" onClick={onReset}>285 Reset All State286 </Button>287 </CardAction>288 </CardHeader>289 <CardContent className="space-y-4">290 <div className="grid gap-2 text-xs text-muted-foreground">291 <div className="flex justify-between">292 <span className="font-medium">Focused Cell:</span>293 <span className="text-foreground">294 {formatPos(grid.focusedCell)}295 </span>296 </div>297 <div className="flex justify-between">298 <span className="font-medium">Editing Cell:</span>299 <span className="text-foreground">300 {formatPos(grid.editingCell)}301 </span>302 </div>303 <div className="flex justify-between">304 <span className="font-medium">Selection Anchor:</span>305 <span className="text-foreground">306 {formatPos(grid.selectionAnchor)}307 </span>308 </div>309 <div className="flex justify-between">310 <span className="font-medium">Total Rows:</span>311 <span className="text-foreground">{grid.rows.length}</span>312 </div>313 <div className="flex justify-between">314 <span className="font-medium">Is Dirty:</span>315 <span className="text-foreground">316 {changes.isDirty ? "Yes" : "No"}317 </span>318 </div>319 <div className="flex justify-between">320 <span className="font-medium">Dirty Row Ids:</span>321 <span className="text-foreground">322 {changes.dirtyRowIds.size}323 </span>324 </div>325 <div className="flex justify-between">326 <span className="font-medium">Failed Row Ids:</span>327 <span className="text-foreground">328 {changes.failedRowIds.size}329 </span>330 </div>331 <div className="flex justify-between">332 <span className="font-medium">Can Undo / Redo:</span>333 <span className="text-foreground">334 {grid.canUndo ? "Yes" : "No"} / {grid.canRedo ? "Yes" : "No"}335 </span>336 </div>337 <div className="flex justify-between">338 <span className="font-medium">Last Commit:</span>339 <span className="text-foreground">340 {grid.lastCommit341 ? `${grid.lastCommit.kind} (seq ${grid.lastCommit.seq})`342 : "None"}343 </span>344 </div>345 </div>346
347 <Collapsible>348 <CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground [&[data-state=open]>svg]:rotate-90">349 <ChevronRight className="size-3.5 transition-transform" />350 View Full State Object351 </CollapsibleTrigger>352 <CollapsibleContent>353 <pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-[10px] leading-relaxed">354 {JSON.stringify(355 {356 focusedCell: grid.focusedCell,357 editingCell: grid.editingCell,358 selectionAnchor: grid.selectionAnchor,359 canUndo: grid.canUndo,360 canRedo: grid.canRedo,361 lastCommit: grid.lastCommit,362 rowCount: grid.rows.length,363 isDirty: changes.isDirty,364 dirtyRowIds: [...changes.dirtyRowIds],365 failedRowIds: [...changes.failedRowIds],366 rows: grid.rows,367 },368 null,369 2,370 )}371 </pre>372 </CollapsibleContent>373 </Collapsible>374 </CardContent>375 </Card>376 </div>377 )378}Installation
Section titled “Installation”pnpm dlx shadcn@latest add @niko-table/data-table-grid @niko-table/data-table-grid-changesPass the same initialRows you seeded the grid with. Those ids are “existing”; everything else is a create. For a blank import, pass [].
1const grid = useDataGrid<Row>({ columnIds, createEmptyRow, initialRows })2const changes = useGridChanges(grid, { initialRows })3
4changes.isDirty5changes.getChangeSet() // { created, updated, deleted }6changes.reconcile(result)| Member | Purpose |
|---|---|
dirtyRowIds / isDirty |
Reactive dirty set |
getChangeSet() |
Precise delta for save |
reconcile({ succeededIds, failedIds }) |
Settle after save |
failedRowIds |
Rows that failed last reconcile |
reset(rows?) |
Re-baseline |
Dirtiness is O(1) per keystroke. getChangeSet() is the precise pass (optionally via isEqual so revert-to-original drops from updated).
Staging save
Section titled “Staging save”1async function onSave() {2 const { created, updated, deleted } = changes.getChangeSet()3 const result = await api.bulkSave({ created, updated, deleted })4 changes.reconcile({5 succeededIds: result.succeededIds,6 failedIds: result.failedIds,7 })8}Autosave
Section titled “Autosave”Debounce the network off dirtyRowIds (identity changes only when dirtiness changes):
1React.useEffect(() => {2 if (!changes.isDirty) return3 const t = setTimeout(async () => {4 const delta = changes.getChangeSet()5 const result = await api.bulkSave(delta)6 changes.reconcile(result)7 }, 800)8 return () => clearTimeout(t)9}, [changes.dirtyRowIds])Signal
Section titled “Signal”Driven by grid.lastCommit: { kind, ids?, seq }. seq advances only on real commits. You can observe it directly for badges or analytics.