Better Tables

Inline Editing

Per-column .editable() with monolith server actions, HTTP writes, or full-control callbacks.

Enable per-column with .editable() on the builder. Double-click (or Enter on a focused cell) opens a type-appropriate editor; Enter/blur commits; Escape cancels. Option/boolean/date editors commit on selection/toggle/day pick.

t.text('subject').editable()
t.option('status').options([...]).editable()
t.boolean('slaBreached').editable({ when: (row) => row.status !== 'resolved' })
t.number('reopenCount').editable({ field: 'reopenCount' }) // field override when id ≠ storage key

editable() takes true or an EditableConfig — the complete set:

EditableConfig fieldPurpose
when(row)Per-row gate — return false to render that row's cell read-only
fieldStorage field written by the adapter save path (defaults to the column id; required when the id is not the storage key; setting it on a relationship path makes the save an own-table write instead). Relationship paths like customer.company are not editable through saveAction alone — the UI needs an adapter with resolveCellWriteTarget to resolve the write target, and adapter-path saves additionally need features.update + updateRecord — see Joined-table editing below. Unsupported / unresolvable paths fall back to callback-only (onCellEdit) or read-only
multilineText editor renders a textarea instead of a single-line input
placeholderPlaceholder for empty text/number editors
editRenderer(props)Custom editor (and the only editor for custom columns) — receives value, row, column, commit(value), cancel(); the table still owns save/validation/rollback

Validation

Attach rules with .validation(rules) on any column — they run before every save path (callback, action, or adapter), and a failing rule blocks the save and shows the message on the cell:

t.text('subject')
  .editable()
  .validation([
    {
      id: 'subject-length',
      validate: (value) => value.length >= 3 || 'Subject needs at least 3 characters',
    },
    { id: 'no-caps', validate: (value) => value !== value.toUpperCase(), message: 'No shouting' },
  ])

Each ValidationRule is { id, validate, message? }. validate returns true to pass, false to fail with message, or a string used as the error message directly.

Persistence paths

Pick by deployment shape:

#DeploymentWiringNotes
1Monolith (recommended) — Next.js, TanStack Starttables.cellEditAction(def) through the framework server boundary, passed as saveActionZero boilerplate: allow-list, coercion, and write target derive from the table definition
2Split frontend/backendhttpAdapter({ url, writes: true }) + handler writes + authorizeDouble opt-in; see HTTP adapter
3Full controlonCellEdit callbackYou own persistence; wins over every other path

Save resolution

First match wins:

ConditionResult
editing={false} on <BetterTable>read-only
column has no editable / when(row) falseread-only
onCellEdit providedcallback save
saveAction providedserializable action save
adapter features.update + updateRecord + resolvable target + row idadapter save
otherwiseread-only (+ one dev console.warn per column)

Joined-table editing

A relationship-path column (t.text('customer.company').editable()) edits the related row — the write target resolves through the adapter's resolveCellWriteTarget. One-to-many paths are never cell-editable. Row-level authorization stays the app's concern (authorize / wrap the action).

Optimistic updates show immediately; on failure the cell rolls back and onCellEditError fires. Validation rules run before any save. Edits are last-write-wins.

V1 editors

text (+ email/url/phone), number (+ currency/percentage), option, boolean, date. multiOption / json stay read-only; custom needs editable.editRenderer.

Option choices resolve as: declared .options() → schema enum (via resolveTableColumns) → lazy adapter.getFilterOptions(columnId) on first open.

<BetterTable
  table={ticketsTable}
  data={rows}
  adapter={adapter}
  saveAction={saveTicketCell}
/>

On this page