Skip to content

Error handling

The SDK gives you three layers of error reporting:

  1. Connection-levelstatus / isAvailable / error from useGrist().
  2. Action-levelactionStatus / actionError from writes.
  3. Per-cellsafeParse cell results with issues[].

Connection state

ts
type GristWidgetStatus = "booting" | "unavailable" | "ready" | "error"
StatusMeaning
bootingSDK is waiting for grist.ready to resolve.
unavailablePage is not in an iframe / grist global never appeared. Show a "open inside Grist" message.
readyUse the rest of the API.
errorgrist.ready threw, a subscription failed, the heartbeat lost the link, or Grist granted less access than requiredAccess (see below). error is populated.

status is a four-state projection of the underlying FSM. If you need finer granularity — distinguishing "configuring mappings" from "booting", or "link is stale but not yet lost" — read the snapshot directly via useGristHandshake() / useGristCapabilities(). See the handshake guide and the API reference.

<GristBoundary> renders centered, user-facing fallbacks for each blocking state (booting, not embedded, errors, and gate="canRender" while mappings are pending). Messages point to concrete steps in Grist (Creator Panel, access level, column mapping). Override any slot with bootingFallback, preparingFallback, unavailableFallback, or errorFallback, or reuse GristBoundaryScreen for a matching layout.

tsx
<GristWidgetProvider options={...}>
  <GristBoundary
    bootingFallback={<MyCustomSpinner />}
    unavailableFallback={({ reload, isReloading }) => (
      <GristBoundaryScreen
        title="Open inside Grist"
        message="Add this URL as a custom widget in your document."
        showRetry
        actions={{ reload, isReloading }}
      />
    )}
    errorFallback={(error, { reload, isReloading }) => (
      <GristBoundaryScreen
        title="Something went wrong"
        message={error}
        tone="error"
        showRetry
        actions={{ reload, isReloading }}
        retryLabel="Try again"
      />
    )}
  >
    <App />
  </GristBoundary>
</GristWidgetProvider>

The unavailableGraceMs prop (default 5000) delays the unavailable UI to absorb slow handshakes on cold-starts.

Document access level

After grist.ready, Grist reports the effective access level on each grist.onOptions callback as interaction.access_level (none, read table, or full). The SDK compares that level to requiredAccess from <GristWidgetProvider> / grist.ready. If the user sets No document access in the Creator Panel while the widget requested read table, status becomes error and <GristBoundary> shows an insufficient-access message instead of your widget body. You can still read the level from w.widgetInteraction?.access_level for custom copy.

Action errors

Every write through w.table.*, w.getTable(id).*, and w.applyActions(...) updates two fields:

ts
w.actionStatus  // "idle" | "running" | "error"
w.actionError   // string | null

You can either:

  • React in your UI directly (w.actionStatus === "running").
  • Catch the rejection at the call site.
tsx
async function save() {
  try {
    await w.table.update({ id, fields: patch })
  } catch (err) {
    toast.error((err as Error).message)
  }
}

The SDK never swallows action errors — they bubble out of the promise. The status fields are additionally updated for the case where you want a single source of truth across many call sites.

Column mapping incompleteness

When the user hasn't mapped required columns, you'll see:

ts
w.columnMappingStatus.ok === false
w.mappedRecord === null

Gate UI on w.columnMappingStatus.ok and tell the user which columns are missing — this is rarely an "error" condition, more an interactive prompt.

When the custom section is not a link target in Grist (w.widgetInteraction.linking.asTarget === null), prefer the section-not-linked alert from getGristSdkAlertDescriptors instead of a generic “Select a row” message — including when w.mode === "row" but the row is stale because the section was never linked. Wrap your app with <GristSdkAlerts> (see the Vite template main.tsx). Requires Grist v1.7.13+ (or a host that reports linking on onOptions; older builds omit it and the SDK stays silent).

While w.columnMappingStatus.pending === true (mappings not yet reported by Grist after load), treat mapping as unknown: do not show the incomplete-mapping prompt yet. The SDK can emit a soft mapping-pending alert (info) via getGristSdkAlertDescriptors.

The SDK ships an alert builder (and a React hook that attaches the handshake snapshot automatically):

ts
import {
  getGristSdkAlertDescriptors,
  useGristSdkAlertDescriptors,
} from "grist-widget-sdk"

const alerts = getGristSdkAlertDescriptors(w, {
  columnMappingHint: "Open the gear icon to configure columns.",
  // Optional: pass snapshot from useGristHandshake() for link-stale, etc.
})
// alerts: [{ id, kind, title, severity, ariaRole, message }, ...]

// In React (inside GristWidgetProvider):
const alerts = useGristSdkAlertDescriptors()
KindWhenseverity
mapping-pendingcolumnMappingStatus.pendinginfo
column-mappingRequired columns missing / empty multipleswarning
mapping-unreportedFSM config.mappings.state === "unreported" (needs snapshot)warning
link-staleFSM link.state === "stale" (needs snapshot)warning
section-not-linkedwidgetInteraction.linking.asTarget === null (Grist page linking, v1.7.13+)warning
current-table-errorsync.currentTable errored (needs snapshot)error
action-erroractionError set while still readyerror
map-back-skipmapBackSkipped non-emptywarning

Map severity to your Alert tokens (info / warning / error). See template GristSdkAlerts for shadcn wiring.

Blocking vs non-blocking

  • Blocking<GristBoundary>: booting / unavailable / error (and optional gate="canRender" while mappings are not yet usable). Boot labels follow handshake phase when a manager is mounted (deriveBoundaryBootLabel).
  • Non-blocking — alerts above while status === "ready".

Cell-level errors

Grist's wire format includes a tagged error sentinel: ["E", "TypeError", "expected number", traceback?]. The SDK exposes:

ts
import { isGristCellError, decodeGristValue } from "grist-widget-sdk"

if (isGristCellError(rawValue)) {
  // display in a tooltip, etc.
}

const decoded = decodeGristValue(rawValue)
if (decoded && typeof decoded === "object" && "__error" in decoded) {
  // decoded.type, decoded.message
}

The safe-parse pipeline turns these into a grist_error issue automatically:

ts
const rows = await w.fetchTableRows("Tasks", { columns, safeParse: true })
for (const row of rows) {
  for (const [colId, cell] of Object.entries(row)) {
    if (!cell.ok) console.warn(colId, cell.issues)
  }
}

Retrying

Every fallback in <GristBoundary> receives a reload() function. Internally it calls useGristReady().reload(), which re-runs the handshake and refreshes subscriptions. Use it for "connection lost" scenarios.

tsx
errorFallback={(err, { reload, isReloading }) => (
  <button disabled={isReloading} onClick={reload}>Retry</button>
)}

Unexpected exceptions

The SDK does not install a global React error boundary. Use your framework's normal error boundary mechanism around the widget body:

tsx
<ErrorBoundary FallbackComponent={MyErrorScreen}>
  <App />
</ErrorBoundary>

Released under the ISC License.