Writing data
The SDK supports two write paths, and you should choose between them based on intent — not "size of payload".
| Path | Use 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:
| Option | Default | Meaning |
|---|---|---|
add | true | Insert when no match found. |
update | true | Update when match found. |
onMany | "first" | "first" | "none" | "all" when multiple rows match require. |
allowEmptyRequire | false | Allow 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:
| Builder | Action 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)
}
}