Cell Editing

Edit cell values inline with validation, formatting, and multiple input types.

How to Enable

  1. Set enableCellEditing: true on the table options
  2. Set editable: true on columns that should be editable
  3. Provide an editConfig with the editor type
  4. Handle commits via onEditCommit

Edit Config

interface CellEditConfig {
  type: 'text' | 'number' | 'select' | 'toggle' | 'date' | 'checkbox' | 'custom'
  options?: { label: string; value: unknown }[] // For 'select' type
  getOptions?: (row: Row) => { label: string; value: unknown }[] // Dynamic options
  validate?: (value, row) => string | null // Return error message or null
  parse?: (inputValue: string) => TValue // Parse input string to typed value
  format?: (value: TValue) => string // Format value for display
  placeholder?: string
  render?: (props: CellEditRenderProps) => unknown // Custom editor render
  commit?: (row: Row<TData>, value: TValue) => void // Per-column commit handler
}

Example

const columns = [
  columnHelper.accessor('name', {
    header: 'Name',
    editable: true,
    editConfig: {
      type: 'text',
      placeholder: 'Enter name...',
      validate: (value) => {
        if (!value) return 'Required'
        if (String(value).length < 2) return 'Too short'
        return null
      },
    },
  }),
  columnHelper.accessor('status', {
    header: 'Status',
    editable: true,
    editConfig: {
      type: 'select',
      options: [
        { label: 'Active', value: 'active' },
        { label: 'Inactive', value: 'inactive' },
        { label: 'Pending', value: 'pending' },
      ],
    },
  }),
  columnHelper.accessor('approved', {
    header: 'Approved',
    editable: true,
    editConfig: { type: 'toggle' },
  }),
]

const table = useTable({
  data,
  columns,
  enableCellEditing: true,
  onEditCommit: (changes) => {
    // changes: Record<rowId, Partial<TData>>
    console.log('Committed:', changes)
  },
})

Commit payloads are keyed by column id

The onEditCommit payload is Record<rowId, Record<columnId, value>> — keyed by column id, not data-field path. For a string-path column the id defaults to the path, so the patch looks like a Partial<TData>. But a function / derived accessor sets its own id, and the committed value lands under that id, not under any real field:

columnHelper.accessor((row) => row.pricing.tier1, {
  id: 'tier1Price', // commit key is 'tier1Price', not 'pricing.tier1'
  editable: true,
  editConfig: { type: 'number' },
})

// onEditCommit receives: { [rowId]: { tier1Price: 42 } }
// NOT { [rowId]: { pricing: { tier1: 42 } } }

Per-column commit handler

editConfig.commit(row, value) fires once for every committed value in that column — on single-cell commit, full-row commit, and commitAllPending() — with the pre-commit row and the new value. It lets the column-id → data-field mapping live on the column def instead of a switch (columnId) inside onEditCommit:

columnHelper.accessor((row) => row.pricing.tier1, {
  id: 'tier1Price',
  editable: true,
  editConfig: {
    type: 'number',
    commit: (row, value) => updateTierPrice(row.original.id, 'tier1', value),
  },
})

commit fires regardless of whether onEditCommit / onCommit is also set — if both are defined, both run, so pick one owner per column.

Editors render automatically

An editConfig renders the matching built-in editor as soon as a cell enters edit mode — you do not need to hand-wire <CellInput> in a cell renderer. The type maps to: text/number/email/url/tel → text input, select → dropdown, toggle/checkbox → checkbox, date → date input, and custom (with render) → your own editor.

A column may define both a custom cell renderer (styled display) and an editConfig. The custom cell is used for display mode only; while editing, the configured editor takes over automatically:

columnHelper.accessor('price', {
  editable: true,
  editConfig: { type: 'number' },
  cell: (ctx) => <span className="tabular-nums">{fmt(ctx.getValue())}</span>,
})

For a fully custom editor use editConfig.render; to render your own editor inside the cell, branch on ctx.cell.getIsEditing() and omit editConfig.

Table Methods

table.startEditing(rowId, columnId) // Enter edit mode on a cell
table.commitEdit() // Commit the active edit
table.cancelEdit() // Cancel the active edit
table.setPendingValue(rowId, columnId, value) // Set a pending value
table.getPendingValue(rowId, columnId) // Read a pending value
table.getAllPendingChanges() // Get all uncommitted changes
table.hasPendingChanges() // Check if any changes are pending
table.commitAllPending() // Commit all pending changes at once
table.discardAllPending() // Discard all pending changes
table.getValidationErrors() // Get validation errors
table.isValid() // Check if all pending values are valid

Full-Row Editing Controls

Use RowEditControls in a display column to switch a row between view mode and full-row edit mode. The control renders Edit, Save, and Cancel actions, disables Save while row validators fail, and works in both desktop table rows and adaptive card layouts.

import { CellInput, RowEditControls, createColumnHelper } from '@zvndev/yable-react'

const columnHelper = createColumnHelper<Employee>()

const columns = [
  columnHelper.accessor('name', {
    header: 'Name',
    editable: true,
    editConfig: { type: 'text' },
    cell: (context) =>
      context.table.isRowEditing(context.row.id) ? (
        <CellInput context={context} />
      ) : (
        context.getValue()
      ),
  }),
  columnHelper.display({
    id: 'actions',
    header: 'Actions',
    cell: (context) => <RowEditControls context={context} />,
  }),
]

When a row is editing, bundled form controls defer blur, Enter, Escape, and Tab behavior to the row edit session: Enter saves the row, Escape cancels, and Tab cycles through editable cells in that row.

Always-Editable Cells

To make a cell always show its editor (like a spreadsheet), set alwaysEditable in the column meta:

columnHelper.accessor('quantity', {
  header: 'Qty',
  editable: true,
  editConfig: { type: 'number' },
  meta: { alwaysEditable: true },
})