Skip to content

Writing data

The SDK supports two write paths, and you should choose between them based on intent — not "size of payload".

PathUse it for
TableOperationsw.table.* / w.getTable(id).*All record CRUD (single or bulk).
w.applyActions([...])Schema mutations, multi-action atomic updates, custom undo descriptions.

Both update w.actionStatus ("idle" | "running" | "error") and w.actionError so your UI can show a spinner or alert from one place.

TableOperations

w.table is a TableOperations handle on the currently selected table. w.getTable(tableId) returns one for any table id (and is memoized).

ts
type GristTableOperations = {
  getTableId(): Promise<string>
  create(records, options?): Promise<{id: number} | {id: number}[]>
  update(records, options?): Promise<void>
  upsert(records, options?): Promise<void>
  destroy(recordIds: number | number[]): Promise<void>
}

Each method accepts a single record or an array of records natively.

Create

ts
await w.table.create({ fields: { Title: "Buy milk" } })

await w.getTable("Tasks").create([
  { fields: { Title: "Buy milk" } },
  { fields: { Title: "Walk dog" } },
])

Returns { id } for single, { id }[] for bulk.

Update

ts
await w.table.update({ id: 42, fields: { Done: true } })

await w.getTable("Tasks").update([
  { id: 1, fields: { Done: true } },
  { id: 2, fields: { Done: true } },
])

Upsert

upsert matches on a require key set, then updates or inserts:

ts
await w.getTable("Contacts").upsert({
  require: { Email: "[email protected]" },
  fields: { Name: "Alice", LastSeen: new Date() },
})

Options:

OptionDefaultMeaning
addtrueInsert when no match found.
updatetrueUpdate when match found.
onMany"first""first" | "none" | "all" when multiple rows match require.
allowEmptyRequirefalseAllow no-key upsert (advanced; usually a mistake).
parseStringsPass strings through Grist's parser (dates, numbers).

Destroy

ts
await w.table.destroy(42)
await w.getTable("Tasks").destroy([1, 2, 3])

Mapping logical fields on writes

The most common pattern: read mapped, write real. Use w.mapBack(...) to translate.

tsx
async function toggle(w, m) {
  await w.table.update({
    id: w.record!.id,
    fields: w.mapBack({ Done: !m.Done }),
  })
}

Pitfall: allowMultiple columns can't be reverse-mapped automatically — you have to pick which real column to write.

Schema mutations via applyActions

For changes beyond record CRUD, compose user-action builders and pass them to w.applyActions(actions, { desc }).

ts
import {
  gristAddColumnAction,
  gristRenameColumnAction,
  gristAddTableAction,
  gristReplaceTableDataAction,
} from "grist-widget-sdk"

await w.applyActions(
  [
    gristAddTableAction("Reports", {
      columns: [
        { id: "Title", type: "Text" },
        { id: "Priority", type: "Choice" },
      ],
    }),
    gristAddColumnAction("Reports", "CreatedAt", { type: "DateTime" }),
    gristRenameColumnAction("Reports", "Title", "Name"),
  ],
  { desc: "Bootstrap Reports table" },
)

Available builders:

BuilderAction tuple
gristAddRecordAction(tableId, values)["AddRecord", tableId, null, values]
gristUpdateRecordAction(tableId, rowId, patch)["UpdateRecord", ...]
gristRemoveRecordAction(tableId, rowId)["RemoveRecord", ...]
gristBulkAddRecordAction(tableId, columnarValues, rowIds?)["BulkAddRecord", ...]
gristBulkUpdateRecordAction(tableId, rowIds, columnarPatch)["BulkUpdateRecord", ...]
gristBulkRemoveRecordAction(tableId, rowIds)["BulkRemoveRecord", ...]
gristAddColumnAction(tableId, colId, colInfo)["AddColumn", ...]
gristModifyColumnAction(tableId, colId, colInfo)["ModifyColumn", ...]
gristRenameColumnAction(tableId, colId, newColId)["RenameColumn", ...]
gristRemoveColumnAction(tableId, colId)["RemoveColumn", ...]
gristAddTableAction(tableId, tableInfo)["AddTable", ...]
gristRenameTableAction(tableId, newTableId)["RenameTable", ...]
gristRemoveTableAction(tableId)["RemoveTable", ...]
gristDuplicateTableAction(tableId, newTableId, options?)["DuplicateTable", ...]
gristReplaceTableDataAction(tableId, rowIds, columnarData)["ReplaceTableData", ...]
gristSetDocumentInfoAction(info)["SetDocumentInfo", info]

You can still write raw tuples (["AddRecord", "Tasks", null, { Title: "x" }]) — builders just make them type-checked and grep-able.

Encoding values for the wire

When values are typed Date, Ref, RefList, etc., Grist expects a specific wire shape. The TableOperations write path handles this for common cases, but applyActions does not. Use encodeGristValue(value, columnMeta):

ts
const cell = encodeGristValue(new Date(), { type: "DateTime:UTC" })
// → 1717599600  (epoch seconds)

const ref = encodeGristValue([1, 2, 3], { type: "RefList:Tasks" })
// → ["L", 1, 2, 3]

Tracking action status

tsx
{w.actionStatus === "running" && <Spinner />}
{w.actionStatus === "error" && <Alert>{w.actionError}</Alert>}

Or — for non-critical errors that shouldn't promote to a full error state — use the descriptors:

ts
const alerts = getGristSdkAlertDescriptors(w)

Optimistic UI

The reactive record / records streams update from Grist after a write completes. For optimistic flows, mutate local state in your onClick and reconcile on the next stream update.

tsx
const [pending, setPending] = useState(false)
async function onToggle() {
  setPending(true)
  try {
    await w.table.update({ id: w.record!.id, fields: { Done: true } })
  } finally {
    setPending(false)
  }
}

Released under the ISC License.