Core concepts
A short mental model for the parts of Grist your widget interacts with, and how the SDK reflects them.
The widget lives in an iframe
A Custom Widget is an iframe embedded by the Grist host. Communication happens through grist-plugin-api.js, which exposes a global grist object and a request/event channel back to the document.
┌─────────────────────────────┐
│ Grist host document │
│ ┌───────────────────────┐ │
│ │ widget iframe │ │
│ │ grist-plugin-api.js │ │
│ │ grist-widget-sdk │ │
│ │ your React widget │ │
│ └───────────────────────┘ │
└─────────────────────────────┘The SDK shields you from the iframe lifecycle, the grist.ready handshake, and the event streams.
The ready handshake
You declare your widget's needs once via grist.ready(...). The SDK does this for you when you mount <GristWidgetProvider> — concurrent calls are coalesced, and the access level is only re-issued if it widens (e.g. from "read table" to "full").
The options you can pass:
<GristWidgetProvider
options={{
requiredAccess: "read table", // or "full" or "none"
columns: [/* see Column mapping */],
allowSelectBy: true, // surface widget as a linking source
hasCustomOptions: true, // show the "Open configuration" gear icon
onEditOptions: () => navigate("/settings"),
}}
>Sections, selection, and modes
A Custom Widget is attached to a section of a Grist page. The section has:
- A current table (
w.currentTableId). - A cursor row — what
grist.onRecordreports asw.record. - A visible row set — what
grist.onRecordsreports asw.records. - A new-row placeholder — when the user clicks the blank trailing row,
grist.onNewRecordfires.
The SDK derives a single w.mode for you:
type GristWidgetMode = "empty" | "row" | "new-row"| mode | meaning |
|---|---|
empty | No row selected (or section is empty). |
row | A real row is selected. w.record is non-null. |
new-row | The user is on the blank "create" row. |
Render branches against mode instead of stitching record == null + isNewRecord flags yourself.
Column mapping
Widgets declare logical column names (the names your code uses). The user maps them to real columns in the section configuration UI. This means a widget that wants Title and Done works on any table that has any two columns the user is willing to map.
The SDK exposes:
w.mappings—{ logicalName → realColId }for the single-record stream.w.recordsMappings— same for the multi-record stream (occasionally diverges).w.mappedRecord—w.recordrewritten under logical names, ornullwhen required mappings are missing.w.columnMappingStatus—{ ok, missing, emptyMultiples }to gate UI.w.mapBack(patch)— reverse-map a logical patch to a real-column patch ready for writes.
See Column mapping for full patterns.
Decoded vs encoded
Grist sometimes sends cell values as marshalled tuples: dates as ["D", epochSeconds, tz], errors as ["E", ...], references as ["R", tableId, rowId], lists as ["L", ...].
The SDK pins decoding on:
w.record/w.records(event streams) — decoded.w.fetchSelectedTable()/w.fetchSelectedRecord()— decoded rows.w.fetchTableRows()— decoded rows when you passcolumnsmetadata.
w.fetchTable() returns the raw columnar payload. Use decodeGristValue(value, column) or safeParseGristTableData(data, options) to decode.
Reads vs writes
| Path | Use for |
|---|---|
w.record / w.records (subscriptions) | Reactive selection — re-renders when Grist updates. |
w.fetchSelectedTable / w.fetchSelectedRecord | One-off read of the current section. |
w.fetchTable / w.fetchTableRows / w.fetchRow | One-off read of any table by id. |
w.table.* / w.getTable(id).* | All record CRUD. |
w.applyActions([...]) | Schema mutations (add/rename/remove tables, columns) or operations needing custom undo descriptions. |
The provider, the boundary, and the hook
These three pieces compose:
<GristWidgetProvider options={...}> // wires up grist.ready + slice contexts
<GristBoundary> // booting / unavailable / error chrome
<YourWidget /> // useGrist()
</GristBoundary>
</GristWidgetProvider>- The provider sets up Grist exactly once and exposes the result via React context.
- The boundary renders fallbacks for
booting,unavailable,error. It hands areload()button to the user automatically. - Inside the boundary, you can assume
status === "ready"and stop guarding.
The handshake state machine
Behind status, the SDK runs a five-axis state machine (lifecycle, link, authz, config, sync). The default useGrist() collapses it down to four states, but you can opt into the full snapshot when you need finer gating — for example, distinguishing "configuring mappings" from "booting", or reacting to a degrading link before it fails. See the handshake guide and the API reference.
The same machine powers heartbeat auto-coalescence: every successful RPC the SDK issues (writes, reads, linking, attachments…) counts as a free heartbeat ping, so chatty widgets don't burn extra requests on background health checks.
Tests look like production
renderWithGrist(ui, { emulator: { document } }) mounts an in-process emulator behind window.grist so the SDK takes its real code path. Records, mappings, writes, options, attachments — everything works.
import { renderWithGrist } from "grist-widget-sdk/emulator/testing"
const { emulator, getByText } = renderWithGrist(<MyWidget />, {
emulator: { document: presets.todoList() },
})
emulator.mutate.addRow("Tasks", { Title: "Buy milk" })See Testing.