Skip to content

Raw plugin API vs SDK

Grist ships grist-plugin-api.js, which exposes a global grist object in your widget iframe. The grist-widget-sdk package wraps that bridge for React: one handshake, decoded values, mapping helpers, and tests without opening a real document.

Use this page when you are tempted to call grist directly in widget code — or when you are migrating an older custom widget.

Mental model

text
Grist host (full frontend, doc already loaded)
  ↔ plugin API channel (not “your widget calling HTTPS for table data”)
    ↔ grist-widget-sdk (React)
      ↔ your components

Most widget reads/writes are RPC to the host runtime (grist.docApi, onRecord, …). Only REST helpers (fetchWithAuth, attachment URLs) hit HTTP.

Comparison

Taskgrist-plugin-api (raw)grist-widget-sdk
Handshakegrist.ready({ requiredAccess, columns, … }) in every app; coalesce yourself<GristWidgetProvider options={…}> once
Boot / not in GristCustom detection + loading UI<GristBoundary> (bootingFallback, unavailableFallback, errorFallback)
Selected rowgrist.onRecord(cb) + interpret null / new rowuseGrist()w.record, w.mode (empty | row | new-row)
Decoded values (Date, ref, …)Manual decode of wire tuplesDecoded by default on section reads; decodeGristValue exported
Column mappingmapColumnNames / mapColumnNamesBack + config panelw.mappedRecord, w.mapBack(), w.columnMappingStatus, alerts
Gate UI until mappedCustom checksw.capabilities.canRender + <GristBoundary gate="canRender">
Update one fieldBuild UpdateRecord user-action tuplew.table.update({ id, fields })
Bulk row CRUDapplyActions([…]) tuplesw.table.create / update / upsert / destroy (single or array)
Schema changesHand-built action tuplesw.applyActions([gristAddColumnAction(…), …])
TypeScriptLoose globaluseGrist<MyRow, MyMapped>() + exported types
Tests in CIMock window.grist by handrenderWithGrist + presets (same code paths as production)
Fine-grained FSMNot provideduseGristHandshake() on grist-widget-sdk/advanced (optional)

Side-by-side snippets

Handshake + first row

Raw

js
grist.ready({ requiredAccess: "read table" }).then(() => {
  grist.onRecord((record) => {
    if (!record) {
      /* empty? new row? — ambiguous without more listeners */
      return
    }
    root.render(<App record={record} />)
  })
})

SDK

tsx
<GristWidgetProvider options={{ requiredAccess: "read table" }}>
  <GristBoundary>
    <App />
  </GristBoundary>
</GristWidgetProvider>

function App() {
  const w = useGrist()
  if (w.mode === "empty") return <p>Select a row.</p>
  if (w.mode === "new-row") return <p>New row</p>
  return <p>Row {String(w.record!.id)}</p>
}

Declared columns + write

Raw

js
grist.ready({
  requiredAccess: "full",
  columns: [{ name: "Done", type: "Bool" }],
})
// … onRecord …
const patch = grist.mapColumnNamesBack?.({ Done: true })
await grist.docApi.applyUserActions([["UpdateRecord", tableId, id, patch]])

SDK

tsx
export const GRIST_OPTIONS = {
  requiredAccess: "full",
  columns: [{ name: "Done", type: "Bool" }],
} satisfies UseGristOptions

function App() {
  const w = useGrist<TaskRow, { Done: boolean }>()
  if (!w.capabilities.canRender) return <p>Map the Done column in widget settings.</p>
  return (
    <button
      onClick={() =>
        w.table.update({ id: w.record!.id as number, fields: w.mapBack({ Done: true }) })
      }
    >
      Mark done
    </button>
  )
}

When raw grist is still OK

  • Non-React widgets (vanilla JS).
  • Prototyping inside the browser console.
  • Inside the SDK repo — the package owns the global once.

Widget apps should import grist-widget-sdk only; the template ESLint rule blocks accidental grist usage.

Next steps

Released under the ISC License.