ShadTable
DiscordGitHub

ShadTable

A collection of composable table components built on shadcn/ui and TanStack Table.

Editable Table

Click a cell to edit it inline. Edits validate on commit, apply optimistically before a simulated save resolves, roll back on a simulated failure, and can be undone one at a time.

npx shadcn add https://shadcn-table-library.vercel.app/r/editable-table.json

Click a Name, Price, or Stock cell to edit it.

SKUNamePriceStockCategory
SKU-001Electronics
SKU-002Electronics
SKU-003Home
SKU-004Clothing
SKU-005Home

How it works

1.Editing state lives outside TanStack Table entirely

editingCell, draftValue, errors, and pendingCells are plain useState — none of it is a TanStack Table feature. Each editable cell just reads that local state to decide whether to render an <Input> or the formatted value, the same way the Tree Table reads expanded state to decide whether to show children.

src/components/editable/data-table.tsx
const [editingCell, setEditingCell] = useState<{ rowId: string; columnId: string } | null>(null)
const [draftValue, setDraftValue] = useState('')
const [errors, setErrors] = useState<Record<string, string>>({})
const [pendingCells, setPendingCells] = useState<Set<string>>(new Set())

2.Validation runs once, at commit — not on every keystroke

field.validate(draftValue) only runs when the user presses Enter or the input blurs. An invalid value never reaches data — commitEdit exits edit mode without writing anything, and flashError puts the error message where the value used to be for two seconds before clearing itself.

src/components/editable/data-table.tsx
const error = field.validate(draftValue)
if (error) {
flashError(key, error)
setEditingCell(null)
return
}

3.The row updates before the save finishes — an optimistic update

commitEdit writes the new value into data immediately, synchronously, through onDataChange. Only after that does saveOptimistically kick off a fake 600ms network call — the cell is already showing the new value the whole time that's in flight, just dimmed via the pending flag instead of being locked.

src/components/editable/data-table.tsx
onDataChange((prev) =>
prev.map((r) => (r.id === rowId ? { ...r, [columnId]: parsedValue } : r)),
)
setHistory((prev) => [...prev, { rowId, columnId, previousValue }])
saveOptimistically(rowId, columnId, previousValue, key)

4.A failed save rolls back automatically

saveOptimistically simulates a 15% failure rate. On failure it writes previousValue straight back into data and removes that edit from the undo history — there's nothing to undo, since the save never actually went through — then flashes "Save failed — reverted" on that exact cell.

src/components/editable/data-table.tsx
if (failed) {
onDataChange((prev) =>
prev.map((r) => (r.id === rowId ? { ...r, [columnId]: previousValue } : r)),
)
// ...remove the matching entry from history
flashError(key, 'Save failed — reverted')
}

5.Undo pops one { rowId, columnId, previousValue } at a time

Every successful commit pushes its previous value onto a history stack. The Undo button pops the most recent entry and writes previousValue back — deliberately bypassing the optimistic-save simulation, since undo is correcting a local mistake, not re-submitting to a server, so it's assumed to always succeed.

src/components/editable/data-table.tsx
function undo() {
setHistory((prev) => {
if (prev.length === 0) return prev
const last = prev[prev.length - 1]
onDataChange((data) =>
data.map((r) => (r.id === last.rowId ? { ...r, [last.columnId]: last.previousValue } : r)),
)
return prev.slice(0, -1)
})
}