Skip to content

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.

text
┌─────────────────────────────┐
│ 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:

tsx
<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.onRecord reports as w.record.
  • A visible row set — what grist.onRecords reports as w.records.
  • A new-row placeholder — when the user clicks the blank trailing row, grist.onNewRecord fires.

The SDK derives a single w.mode for you:

ts
type GristWidgetMode = "empty" | "row" | "new-row"
modemeaning
emptyNo row selected (or section is empty).
rowA real row is selected. w.record is non-null.
new-rowThe 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.record rewritten under logical names, or null when 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 pass columns metadata.

w.fetchTable() returns the raw columnar payload. Use decodeGristValue(value, column) or safeParseGristTableData(data, options) to decode.

Reads vs writes ​

PathUse for
w.record / w.records (subscriptions)Reactive selection — re-renders when Grist updates.
w.fetchSelectedTable / w.fetchSelectedRecordOne-off read of the current section.
w.fetchTable / w.fetchTableRows / w.fetchRowOne-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:

tsx
<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 a reload() 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.

ts
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.

Released under the ISC License.