Grist Widgets
Guide

Writing data

Choosing between the SDK's two write paths based on what you're doing, not how much data it is.

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

PathUse it for
TableOperations — w.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).

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

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

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:

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).
parseStrings—Pass strings through Grist's parser (dates, numbers).

Destroy

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.

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 }).

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):

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

{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:

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.

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)
  }
}

On this page