Inline Edit Table
Edit rows in-place with React.memo-safe cell re-rendering via getRowMemoKey.
"use client"
import * as React from "react"import { DataTableRoot } from "@/components/niko-table/core/data-table-root"import { DataTable } from "@/components/niko-table/core/data-table"import { DataTableHeader, DataTableBody, DataTableEmptyBody,} from "@/components/niko-table/core/data-table-structure"import { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"import { DataTableColumnSortMenu } from "@/components/niko-table/components/data-table-column-sort"import { DataTableToolbarSection } from "@/components/niko-table/components/data-table-toolbar-section"import { DataTableEmptyIcon, DataTableEmptyMessage, DataTableEmptyTitle, DataTableEmptyDescription,} from "@/components/niko-table/components/data-table-empty-state"import { DataTableSearchFilter } from "@/components/niko-table/components/data-table-search-filter"import { DataTablePagination } from "@/components/niko-table/components/data-table-pagination"import { SYSTEM_COLUMN_IDS } from "@/components/niko-table/lib/constants"import type { DataTableColumnDef } from "@/components/niko-table/types"import { Button } from "@/components/ui/button"import { Input } from "@/components/ui/input"import { Badge } from "@/components/ui/badge"import { Pencil, Check, X, PackageSearch, AlertCircle } from "lucide-react"import { cn } from "@/lib/utils"
// ---------------------------------------------------------------------------// Types// ---------------------------------------------------------------------------
type Product = { id: string name: string category: string price: number stock: number}
type EditDraft = { name: string; price: string }type EditErrors = { name?: string; price?: string }
// ---------------------------------------------------------------------------// Static data// ---------------------------------------------------------------------------
const initialData: Product[] = [ { id: "1", name: "Wireless Headphones", category: "Electronics", price: 79.99, stock: 120, }, { id: "2", name: "Running Shoes", category: "Sports", price: 59.99, stock: 85, }, { id: "3", name: "Coffee Maker", category: "Appliances", price: 49.99, stock: 200, }, { id: "4", name: "Yoga Mat", category: "Sports", price: 29.99, stock: 310 }, { id: "5", name: "Desk Lamp", category: "Furniture", price: 39.99, stock: 150, }, { id: "6", name: "Bluetooth Speaker", category: "Electronics", price: 89.99, stock: 65, }, { id: "7", name: "Water Bottle", category: "Sports", price: 19.99, stock: 400, }, { id: "8", name: "Air Purifier", category: "Appliances", price: 129.99, stock: 40, },]
// ---------------------------------------------------------------------------// Inline-edit hook// ---------------------------------------------------------------------------
function useInlineEdit( _data: Product[], setData: React.Dispatch<React.SetStateAction<Product[]>>,) { const [editingId, setEditingId] = React.useState<string | null>(null) const [draft, setDraft] = React.useState<EditDraft>({ name: "", price: "" }) const [errors, setErrors] = React.useState<EditErrors>({})
const startEditing = React.useCallback((product: Product) => { setEditingId(product.id) setDraft({ name: product.name, price: String(product.price) }) setErrors({}) }, [])
const cancel = React.useCallback(() => { setEditingId(null) setDraft({ name: "", price: "" }) setErrors({}) }, [])
const setField = React.useCallback( (field: keyof EditDraft, value: string) => { setDraft(prev => ({ ...prev, [field]: value })) setErrors(prev => ({ ...prev, [field]: undefined })) }, [], )
const save = React.useCallback(() => { const next: EditErrors = {} if (!draft.name.trim()) next.name = "Name is required" const parsed = parseFloat(draft.price) if (isNaN(parsed) || parsed <= 0) next.price = "Enter a positive price" if (Object.keys(next).length > 0) { setErrors(next) return } setData(prev => prev.map(p => p.id === editingId ? { ...p, name: draft.name.trim(), price: parsed } : p, ), ) cancel() }, [draft, editingId, setData, cancel])
/** * getRowMemoKey — the bridge between external edit state and React.memo'd rows. * * Without this, column `cell` closures capture a snapshot of `editingId`, * `draft`, and `errors` at render time. Typing in the name input changes * `draft` in the parent, but since the memoized BodyRow's props haven't * changed, it never re-renders — the input stays frozen at the initial value. * * Returning a row-specific string that encodes all mutable state ensures * React.memo re-renders only the one row that changed, not the whole table. */ const getRowMemoKey = React.useCallback( (row: Product): string => { if (row.id !== editingId) return "" return `${draft.name}|${draft.price}|${errors.name ?? ""}|${errors.price ?? ""}` }, [editingId, draft, errors], )
return { editingId, draft, errors, startEditing, cancel, setField, save, getRowMemoKey, }}
// ---------------------------------------------------------------------------// Main component// ---------------------------------------------------------------------------
export default function InlineEditTable() { const [data, setData] = React.useState<Product[]>(initialData)
const { editingId, draft, errors, startEditing, cancel, setField, save, getRowMemoKey, } = useInlineEdit(data, setData)
const columns = React.useMemo<DataTableColumnDef<Product>[]>( () => [ { id: "name", accessorKey: "name", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle>Name</DataTableColumnTitle> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => { const isEditing = row.original.id === editingId if (!isEditing) return <span className="font-medium">{row.original.name}</span> return ( <div className="space-y-1"> <Input value={draft.name} onChange={e => setField("name", e.target.value)} className={cn( "h-7 w-48 text-sm", errors.name && "border-destructive", )} autoFocus onKeyDown={e => { if (e.key === "Enter") save() if (e.key === "Escape") cancel() }} /> {errors.name && ( <p className="flex items-center gap-1 text-xs text-destructive"> <AlertCircle className="h-3 w-3" /> {errors.name} </p> )} </div> ) }, meta: { label: "Name" }, enableGlobalFilter: true, }, { id: "category", accessorKey: "category", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle>Category</DataTableColumnTitle> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => ( <Badge variant="secondary">{row.original.category}</Badge> ), meta: { label: "Category" }, enableGlobalFilter: true, }, { id: "price", accessorKey: "price", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle>Price</DataTableColumnTitle> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => { const isEditing = row.original.id === editingId if (!isEditing) return ( <span className="tabular-nums"> ${row.original.price.toFixed(2)} </span> ) return ( <div className="space-y-1"> <div className="relative w-28"> <span className="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-sm text-muted-foreground"> $ </span> <Input value={draft.price} onChange={e => setField("price", e.target.value)} className={cn( "h-7 pl-5 text-sm tabular-nums", errors.price && "border-destructive", )} type="number" min={0} step={0.01} onKeyDown={e => { if (e.key === "Enter") save() if (e.key === "Escape") cancel() }} /> </div> {errors.price && ( <p className="flex items-center gap-1 text-xs text-destructive"> <AlertCircle className="h-3 w-3" /> {errors.price} </p> )} </div> ) }, meta: { label: "Price" }, }, { id: "stock", accessorKey: "stock", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle>Stock</DataTableColumnTitle> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => ( <span className="tabular-nums">{row.original.stock}</span> ), meta: { label: "Stock" }, }, { id: SYSTEM_COLUMN_IDS.ACTIONS, cell: ({ row }) => { const isEditing = row.original.id === editingId if (isEditing) { return ( <div className="flex items-center gap-1"> <Button size="icon" variant="ghost" className="h-7 w-7" onClick={save} title="Save" > <Check className="h-3.5 w-3.5 text-green-600" /> </Button> <Button size="icon" variant="ghost" className="h-7 w-7" onClick={cancel} title="Cancel" > <X className="h-3.5 w-3.5 text-red-600" /> </Button> </div> ) } return ( <Button size="icon" variant="ghost" className="h-7 w-7 opacity-0 group-hover:opacity-100 focus:opacity-100" onClick={() => startEditing(row.original)} title="Edit" > <Pencil className="h-3.5 w-3.5" /> </Button> ) }, }, ], // Intentionally listing all closed-over edit state as deps so column // definitions always reflect fresh state after re-render. // eslint-disable-next-line react-hooks/exhaustive-deps [editingId, draft, errors], )
return ( <DataTableRoot data={data} columns={columns}> <DataTableToolbarSection> <DataTableSearchFilter placeholder="Search products…" /> </DataTableToolbarSection> <DataTable> <DataTableHeader /> {/* * getRowMemoKey is the key integration point. * * Each time the edit draft or errors change for the active row, the * returned string changes for that row, telling React.memo to * re-render it. All other rows keep the same "" string and are * skipped entirely, giving you surgical per-row updates. */} <DataTableBody getRowMemoKey={getRowMemoKey}> <DataTableEmptyBody> <DataTableEmptyMessage> <DataTableEmptyIcon> <PackageSearch /> </DataTableEmptyIcon> <DataTableEmptyTitle>No products found</DataTableEmptyTitle> <DataTableEmptyDescription> Try adjusting your search. </DataTableEmptyDescription> </DataTableEmptyMessage> </DataTableEmptyBody> </DataTableBody> </DataTable> <DataTablePagination /> </DataTableRoot> )}Preview with Controlled State
View Full State Object
[]
{
"pageIndex": 0,
"pageSize": 5
}{}"use client"
/** * Inline Edit Table — controlled state variant. * * This file shows how to extract inline-edit logic into a reusable hook and * wire it into the table with full controlled state for pagination, sorting, * and column visibility. */
import * as React from "react"import type { PaginationState, SortingState, VisibilityState,} from "@tanstack/react-table"import { DataTableRoot } from "@/components/niko-table/core/data-table-root"import { DataTable } from "@/components/niko-table/core/data-table"import { DataTableHeader, DataTableBody, DataTableEmptyBody,} from "@/components/niko-table/core/data-table-structure"import { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"import { DataTableColumnSortMenu } from "@/components/niko-table/components/data-table-column-sort"import { DataTableToolbarSection } from "@/components/niko-table/components/data-table-toolbar-section"import { DataTableEmptyIcon, DataTableEmptyMessage, DataTableEmptyTitle, DataTableEmptyDescription,} from "@/components/niko-table/components/data-table-empty-state"import { DataTableSearchFilter } from "@/components/niko-table/components/data-table-search-filter"import { DataTablePagination } from "@/components/niko-table/components/data-table-pagination"import { DataTableViewMenu } from "@/components/niko-table/components/data-table-view-menu"import { SYSTEM_COLUMN_IDS } from "@/components/niko-table/lib/constants"import type { DataTableColumnDef } from "@/components/niko-table/types"import { Button } from "@/components/ui/button"import { Input } from "@/components/ui/input"import { Badge } from "@/components/ui/badge"import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card"import { Pencil, Check, X, PackageSearch, AlertCircle } from "lucide-react"import { cn } from "@/lib/utils"
// ---------------------------------------------------------------------------// Types// ---------------------------------------------------------------------------
type Product = { id: string name: string category: string price: number stock: number}
type EditDraft = { name: string; price: string }type EditErrors = { name?: string; price?: string }
// ---------------------------------------------------------------------------// Data// ---------------------------------------------------------------------------
const initialData: Product[] = [ { id: "1", name: "Wireless Headphones", category: "Electronics", price: 79.99, stock: 120, }, { id: "2", name: "Running Shoes", category: "Sports", price: 59.99, stock: 85, }, { id: "3", name: "Coffee Maker", category: "Appliances", price: 49.99, stock: 200, }, { id: "4", name: "Yoga Mat", category: "Sports", price: 29.99, stock: 310 }, { id: "5", name: "Desk Lamp", category: "Furniture", price: 39.99, stock: 150, }, { id: "6", name: "Bluetooth Speaker", category: "Electronics", price: 89.99, stock: 65, }, { id: "7", name: "Water Bottle", category: "Sports", price: 19.99, stock: 400, }, { id: "8", name: "Air Purifier", category: "Appliances", price: 129.99, stock: 40, },]
// ---------------------------------------------------------------------------// useInlineEdit — reusable inline-edit hook// ---------------------------------------------------------------------------
function useInlineEdit( setData: React.Dispatch<React.SetStateAction<Product[]>>,) { const [editingId, setEditingId] = React.useState<string | null>(null) const [draft, setDraft] = React.useState<EditDraft>({ name: "", price: "" }) const [errors, setErrors] = React.useState<EditErrors>({})
const startEditing = React.useCallback((product: Product) => { setEditingId(product.id) setDraft({ name: product.name, price: String(product.price) }) setErrors({}) }, [])
const cancel = React.useCallback(() => { setEditingId(null) setDraft({ name: "", price: "" }) setErrors({}) }, [])
const setField = React.useCallback( (field: keyof EditDraft, value: string) => { setDraft(prev => ({ ...prev, [field]: value })) setErrors(prev => ({ ...prev, [field]: undefined })) }, [], )
const save = React.useCallback(() => { const next: EditErrors = {} if (!draft.name.trim()) next.name = "Name is required" const parsed = parseFloat(draft.price) if (isNaN(parsed) || parsed <= 0) next.price = "Enter a positive price" if (Object.keys(next).length > 0) { setErrors(next) return } setData(prev => prev.map(p => p.id === editingId ? { ...p, name: draft.name.trim(), price: parsed } : p, ), ) cancel() }, [draft, editingId, cancel, setData])
/** * getRowMemoKey — composable row-invalidation bridge. * * This is the key integration point between external state and the * React.memo'd body rows inside DataTableBody. * * How it works: * 1. All six body variants (DataTableBody, DataTableVirtualizedBody, etc.) * accept `getRowMemoKey?: (row: TData) => string`. * 2. Internally, each memoized BodyRow receives the returned string as a * `rowMemoKey` prop. React.memo compares it like any other prop. * 3. When you type in the name input, `draft.name` changes → this callback * returns a new string for the editing row → React.memo re-renders only * that row. Every other row keeps `""` and is skipped. * * Composable means you can combine multiple sources of external state: * * const getRowMemoKey = useCallback((row: Product) => * [ * inlineEdit.getRowMemoKey(row), // edit drafts + errors * savingIds.has(row.id) ? "saving" : "", // in-flight mutations * optimisticOverlay(row.id), // any other per-row state * ].join("||"), * [inlineEdit.getRowMemoKey, savingIds, optimisticOverlay]) */ const getRowMemoKey = React.useCallback( (row: Product): string => { if (row.id !== editingId) return "" return `${draft.name}|${draft.price}|${errors.name ?? ""}|${errors.price ?? ""}` }, [editingId, draft, errors], )
return { editingId, draft, errors, startEditing, cancel, setField, save, getRowMemoKey, }}
// ---------------------------------------------------------------------------// Main component// ---------------------------------------------------------------------------
export default function InlineEditStateTable() { const [data, setData] = React.useState<Product[]>(initialData) const [pagination, setPagination] = React.useState<PaginationState>({ pageIndex: 0, pageSize: 5, }) const [sorting, setSorting] = React.useState<SortingState>([]) const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const inlineEdit = useInlineEdit(setData)
const columns = React.useMemo<DataTableColumnDef<Product>[]>( () => [ { id: "name", accessorKey: "name", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle>Name</DataTableColumnTitle> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => { const isEditing = row.original.id === inlineEdit.editingId if (!isEditing) return <span className="font-medium">{row.original.name}</span> return ( <div className="space-y-1"> <Input value={inlineEdit.draft.name} onChange={e => inlineEdit.setField("name", e.target.value)} className={cn( "h-7 w-48 text-sm", inlineEdit.errors.name && "border-destructive", )} autoFocus onKeyDown={e => { if (e.key === "Enter") inlineEdit.save() if (e.key === "Escape") inlineEdit.cancel() }} /> {inlineEdit.errors.name && ( <p className="flex items-center gap-1 text-xs text-destructive"> <AlertCircle className="h-3 w-3" /> {inlineEdit.errors.name} </p> )} </div> ) }, meta: { label: "Name" }, enableGlobalFilter: true, }, { id: "category", accessorKey: "category", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle>Category</DataTableColumnTitle> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => ( <Badge variant="secondary">{row.original.category}</Badge> ), meta: { label: "Category" }, enableGlobalFilter: true, }, { id: "price", accessorKey: "price", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle>Price</DataTableColumnTitle> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => { const isEditing = row.original.id === inlineEdit.editingId if (!isEditing) return ( <span className="tabular-nums"> ${row.original.price.toFixed(2)} </span> ) return ( <div className="space-y-1"> <div className="relative w-28"> <span className="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-sm text-muted-foreground"> $ </span> <Input value={inlineEdit.draft.price} onChange={e => inlineEdit.setField("price", e.target.value)} className={cn( "h-7 pl-5 text-sm tabular-nums", inlineEdit.errors.price && "border-destructive", )} type="number" min={0} step={0.01} onKeyDown={e => { if (e.key === "Enter") inlineEdit.save() if (e.key === "Escape") inlineEdit.cancel() }} /> </div> {inlineEdit.errors.price && ( <p className="flex items-center gap-1 text-xs text-destructive"> <AlertCircle className="h-3 w-3" /> {inlineEdit.errors.price} </p> )} </div> ) }, meta: { label: "Price" }, }, { id: "stock", accessorKey: "stock", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle>Stock</DataTableColumnTitle> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => ( <span className="tabular-nums">{row.original.stock}</span> ), meta: { label: "Stock" }, }, { id: SYSTEM_COLUMN_IDS.ACTIONS, cell: ({ row }) => { const isEditing = row.original.id === inlineEdit.editingId if (isEditing) { return ( <div className="flex items-center gap-1"> <Button size="icon" variant="ghost" className="h-7 w-7" onClick={inlineEdit.save} title="Save" > <Check className="h-3.5 w-3.5 text-green-600" /> </Button> <Button size="icon" variant="ghost" className="h-7 w-7" onClick={inlineEdit.cancel} title="Cancel" > <X className="h-3.5 w-3.5 text-red-600" /> </Button> </div> ) } return ( <Button size="icon" variant="ghost" className="h-7 w-7 opacity-0 group-hover:opacity-100 focus:opacity-100" onClick={() => inlineEdit.startEditing(row.original)} title="Edit" > <Pencil className="h-3.5 w-3.5" /> </Button> ) }, }, ], // Columns capture inlineEdit callbacks. Because the hook returns stable // callbacks (useCallback), this memo only re-runs when edit state changes. // eslint-disable-next-line react-hooks/exhaustive-deps [inlineEdit.editingId, inlineEdit.draft, inlineEdit.errors], )
const resetAllState = React.useCallback(() => { setPagination({ pageIndex: 0, pageSize: 10 }) setSorting([]) setColumnVisibility({}) inlineEdit.cancel() }, [inlineEdit])
return ( <div className="space-y-4"> <DataTableRoot data={data} columns={columns} state={{ pagination, sorting, columnVisibility }} onPaginationChange={setPagination} onSortingChange={setSorting} onColumnVisibilityChange={setColumnVisibility} > <DataTableToolbarSection> <DataTableSearchFilter placeholder="Search products…" /> <DataTableViewMenu /> </DataTableToolbarSection> <DataTable> <DataTableHeader /> <DataTableBody getRowMemoKey={inlineEdit.getRowMemoKey}> <DataTableEmptyBody> <DataTableEmptyMessage> <DataTableEmptyIcon> <PackageSearch /> </DataTableEmptyIcon> <DataTableEmptyTitle>No products found</DataTableEmptyTitle> <DataTableEmptyDescription> Try adjusting your search. </DataTableEmptyDescription> </DataTableEmptyMessage> </DataTableEmptyBody> </DataTableBody> </DataTable> <DataTablePagination /> </DataTableRoot>
{/* State Display for demonstration */} <Card> <CardHeader> <CardTitle>Current Table State</CardTitle> <CardDescription> Live view of the current table state for demonstration purposes </CardDescription> <CardAction> <Button variant="outline" size="sm" onClick={resetAllState}> Reset All State </Button> </CardAction> </CardHeader> <CardContent className="space-y-4"> <div className="grid gap-2 text-xs text-muted-foreground"> <div className="flex justify-between"> <span className="font-medium">Editing Row:</span> <span className="text-foreground"> {inlineEdit.editingId ?? "None"} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Total Items:</span> <span className="text-foreground">{data.length}</span> </div>
<div className="flex justify-between"> <span className="font-medium">Sorting:</span> <span className="text-foreground"> {sorting.length > 0 ? sorting .map(s => `${s.id} ${s.desc ? "desc" : "asc"}`) .join(", ") : "None"} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Page:</span> <span className="text-foreground"> {pagination.pageIndex + 1} (Size: {pagination.pageSize}) </span> </div>
<div className="flex justify-between"> <span className="font-medium">Hidden Columns:</span> <span className="text-foreground"> { Object.values(columnVisibility).filter(v => v === false) .length } </span> </div> </div>
{/* Detailed state (collapsible) */} <details className="border-t pt-4"> <summary className="cursor-pointer text-xs font-medium hover:text-foreground"> View Full State Object </summary> <div className="mt-4 space-y-3 text-xs"> <div> <strong>Sorting:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(sorting, null, 2)} </pre> </div> <div> <strong>Pagination:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(pagination, null, 2)} </pre> </div> <div> <strong>Column Visibility:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(columnVisibility, null, 2)} </pre> </div> </div> </details> </CardContent> </Card> </div> )}Introduction
Section titled “Introduction”The Inline Edit Table lets users edit row fields directly in the table without opening a dialog or navigating away.
Niko Table wraps each body row in React.memo for performance — rows only re-render when their props change. This is great for large tables, but it creates a problem for inline editing: the edit draft and validation errors live outside the row’s props, so memo’d rows won’t pick up changes by default. The getRowMemoKey prop solves this — it lets you return a row-specific string that encodes whatever external state that row depends on. When the string changes, React.memo allows the re-render; when it doesn’t, the row stays frozen.
Design decisions
Section titled “Design decisions”A common approach to inline editing is to embed edit state directly in the data array — each row object carries an isEditing: boolean flag. Triggering an edit means calling setData to flip that flag, which replaces the entire array and causes every memoized row to re-render.
We approach it differently: edit state lives outside the data. editingId, draft, and errors are separate useState variables. The data array is only updated once on save() — not on every keystroke. Callbacks are threaded into column cell closures (or optionally via table.options.meta) rather than through the data shape.
The result is that typing in an input field touches exactly one memoized row, and only because getRowMemoKey returns a new string for it. All other rows are completely skipped by React reconciliation.
| Concern | Embedded state | Our approach |
|---|---|---|
| Edit state location | Inside the data array (isEditing on each row) | Outside data (editingId + draft state) |
| Update on keystroke | setData replaces the whole array → all rows re-render | Only draft changes → one row re-renders via getRowMemoKey |
| Draft & validation | No concept — changes write directly to data | Staged draft with error validation before committing |
| Data mutation | On every interaction | Once, on explicit save() |
Installation
Section titled “Installation”Install the DataTable core and add-ons for this example:
This example also uses input and badge from Shadcn UI:
First time using
@niko-table? See the Installation Guide to set up the registry.
For other add-ons or manual copy-paste, see the Installation Guide.
Prerequisites
Section titled “Prerequisites”We are going to build a table to show products with inline editing. Here’s what our data looks like:
type Product = { id: string name: string category: string price: number stock: number}Building useInlineEdit
Section titled “Building useInlineEdit”Extract all edit logic into a reusable hook. The critical export is getRowMemoKey:
type EditDraft = { name: string; price: string }type EditErrors = { name?: string; price?: string }
function useInlineEdit( setData: React.Dispatch<React.SetStateAction<Product[]>>,) { const [editingId, setEditingId] = useState<string | null>(null) const [draft, setDraft] = useState<EditDraft>({ name: "", price: "" }) const [errors, setErrors] = useState<EditErrors>({})
const startEditing = useCallback((product: Product) => { setEditingId(product.id) setDraft({ name: product.name, price: String(product.price) }) setErrors({}) }, [])
const cancel = useCallback(() => { setEditingId(null) setDraft({ name: "", price: "" }) setErrors({}) }, [])
const setField = useCallback((field: keyof EditDraft, value: string) => { setDraft((prev) => ({ ...prev, [field]: value })) setErrors((prev) => ({ ...prev, [field]: undefined })) }, [])
const save = useCallback(() => { const next: EditErrors = {} if (!draft.name.trim()) next.name = "Name is required" const parsed = parseFloat(draft.price) if (isNaN(parsed) || parsed <= 0) next.price = "Enter a positive price" if (Object.keys(next).length > 0) { setErrors(next) return } setData((prev) => prev.map((p) => p.id === editingId ? { ...p, name: draft.name.trim(), price: parsed } : p, ), ) cancel() }, [draft, editingId, cancel, setData])
/** * getRowMemoKey — the bridge between external state and React.memo. * * Returns "" for every non-editing row (no change → no re-render). * Returns a unique string for the editing row that encodes all mutable * state — draft values and validation errors. * * Each keystroke changes `draft.name` → new string → React.memo * re-renders only this row. All 7 other rows are untouched. */ const getRowMemoKey = useCallback( (row: Product): string => { if (row.id !== editingId) return "" return `${draft.name}|${draft.price}|${errors.name ?? ""}|${errors.price ?? ""}` }, [editingId, draft, errors], )
return { editingId, draft, errors, startEditing, cancel, setField, save, getRowMemoKey }}Column definitions
Section titled “Column definitions”Column cells close over inlineEdit state. They conditionally render the input or the read-only value:
const columns = useMemo<DataTableColumnDef<Product>[]>( () => [ { id: "name", accessorKey: "name", header: ({ column }) => ( <DataTableColumnHeader column={column}> <DataTableColumnTitle>Name</DataTableColumnTitle> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => { const isEditing = row.original.id === inlineEdit.editingId if (!isEditing) return <span className="font-medium">{row.original.name}</span> return ( <Input value={inlineEdit.draft.name} onChange={(e) => inlineEdit.setField("name", e.target.value)} className="h-7 w-48 text-sm" autoFocus onKeyDown={(e) => { if (e.key === "Enter") inlineEdit.save() if (e.key === "Escape") inlineEdit.cancel() }} /> ) }, }, // ... price, category, stock columns { id: SYSTEM_COLUMN_IDS.ACTIONS, cell: ({ row }) => { const isEditing = row.original.id === inlineEdit.editingId if (isEditing) { return ( <div className="flex gap-1"> <Button size="icon" variant="ghost" onClick={inlineEdit.save}> <Check className="h-3.5 w-3.5 text-green-600" /> </Button> <Button size="icon" variant="ghost" onClick={inlineEdit.cancel}> <X className="h-3.5 w-3.5 text-red-600" /> </Button> </div> ) } return ( <Button size="icon" variant="ghost" onClick={() => inlineEdit.startEditing(row.original)} > <Pencil className="h-3.5 w-3.5" /> </Button> ) }, }, ], // Rebuild columns when edit state changes so cells see fresh closures. // eslint-disable-next-line react-hooks/exhaustive-deps [inlineEdit.editingId, inlineEdit.draft, inlineEdit.errors],)Wiring it together
Section titled “Wiring it together”Pass getRowMemoKey directly to DataTableBody:
export function ProductsTable() { const [data, setData] = useState<Product[]>(initialData) const inlineEdit = useInlineEdit(setData)
const columns = useMemo(..., [inlineEdit.editingId, inlineEdit.draft, inlineEdit.errors])
return ( <DataTableRoot data={data} columns={columns}> <DataTable> <DataTableToolbarSection> <DataTableSearchFilter placeholder="Search products…" /> </DataTableToolbarSection> <DataTableHeader /> <DataTableBody getRowMemoKey={inlineEdit.getRowMemoKey} /> <DataTableEmptyBody>...</DataTableEmptyBody> <DataTablePagination /> </DataTable> </DataTableRoot> )}Composing multiple state sources
Section titled “Composing multiple state sources”getRowMemoKey is generic — you can encode any per-row external state into the returned string. Combine sources by joining with a separator:
const [savingIds, setSavingIds] = useState<Set<string>>(new Set())
// useInlineEdit already returns its own getRowMemoKeyconst inlineEdit = useInlineEdit(setData)
// Compose: inline edit state + saving state + any other per-row stateconst getRowMemoKey = useCallback( (row: Product): string => [ inlineEdit.getRowMemoKey(row), // edit draft + errors savingIds.has(row.id) ? "saving" : "idle", // in-flight mutation ].join("||"), [inlineEdit.getRowMemoKey, savingIds],)
// Works with any body variant — virtualized, DnD, virtualized DnDreturn ( <> {/* Non-virtualized */} <DataTableBody getRowMemoKey={getRowMemoKey} />
{/* Or virtualized */} <DataTableVirtualizedBody getRowMemoKey={getRowMemoKey} estimateSize={52} />
{/* Or row-DnD */} <DataTableDndBody getRowMemoKey={getRowMemoKey} /> </>)Keyboard shortcuts
Section titled “Keyboard shortcuts”The example wires up standard keyboard shortcuts inside the input’s onKeyDown:
| Key | Action |
|---|---|
Enter | Save the edit |
Escape | Cancel and revert |