Error handling
The SDK gives you three layers of error reporting:
- Connection-level —
status/isAvailable/errorfromuseGrist(). - Action-level —
actionStatus/actionErrorfrom writes. - Per-cell —
safeParsecell results withissues[].
Connection state
type GristWidgetStatus = "booting" | "unavailable" | "ready" | "error"| Status | Meaning |
|---|---|
booting | SDK is waiting for grist.ready to resolve. |
unavailable | Page is not in an iframe / grist global never appeared. Show a "open inside Grist" message. |
ready | Use the rest of the API. |
error | grist.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.
<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:
w.actionStatus // "idle" | "running" | "error"
w.actionError // string | nullYou can either:
- React in your UI directly (
w.actionStatus === "running"). - Catch the rejection at the call site.
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:
w.columnMappingStatus.ok === false
w.mappedRecord === nullGate 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):
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()| Kind | When | severity |
|---|---|---|
mapping-pending | columnMappingStatus.pending | info |
column-mapping | Required columns missing / empty multiples | warning |
mapping-unreported | FSM config.mappings.state === "unreported" (needs snapshot) | warning |
link-stale | FSM link.state === "stale" (needs snapshot) | warning |
section-not-linked | widgetInteraction.linking.asTarget === null (Grist page linking, v1.7.13+) | warning |
current-table-error | sync.currentTable errored (needs snapshot) | error |
action-error | actionError set while still ready | error |
map-back-skip | mapBackSkipped non-empty | warning |
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 optionalgate="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:
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:
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.
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:
<ErrorBoundary FallbackComponent={MyErrorScreen}>
<App />
</ErrorBoundary>