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".
| 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: "alice@example.com" },
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)
}
}