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