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.jsonClick a Name, Price, or Stock cell to edit it.
| SKU | Name | Price | Stock | Category |
|---|---|---|---|---|
| SKU-001 | Electronics | |||
| SKU-002 | Electronics | |||
| SKU-003 | Home | |||
| SKU-004 | Clothing | |||
| SKU-005 | Home |
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.
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.
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.
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.
if (failed) {onDataChange((prev) =>prev.map((r) => (r.id === rowId ? { ...r, [columnId]: previousValue } : r)),)// ...remove the matching entry from historyflashError(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.
function undo() {setHistory((prev) => {if (prev.length === 0) return prevconst 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)})}