Grist Widgets
API

Handshake module

Reference for the handshake module: full connection snapshot, derived capabilities, and lifecycle controls.

A statechart-driven view of the widget ↔ Grist relationship. Exposes the full snapshot, derived capabilities, and lifecycle controls — useful when the four-state status (booting / ready / unavailable / error) is not enough (degraded networks, schema-write gating, optimistic retries…).

import {
  GristHandshakeProvider,
  useGristHandshake,
  useGristCapabilities,
  useGristHandshakeContext,
  useGristHandshakeContextOptional,
  type GristWidgetSnapshot,
  type GristCapabilities,
} from "grist-widget-sdk/advanced"

Everything in this module is independent of <GristWidgetProvider> / useGrist(). You can mount it alongside, or use it standalone in a tiny widget that just needs the snapshot.

When to use this vs the default surface

GoalUse
Show "Connecting…" → "Ready" UI, retry on error.<GristBoundary> + useGristStatus().
Branch on "can I write rows?" vs "can I render?".useGristCapabilities().
React to "link is stale, going offline soon".useGristHandshake() → snapshot.link.state.
Show a "loading mappings" hint distinct from "ready".snapshot.config.mappings.state.
Drive a custom retry/backoff overlay.useGristHandshake() → reload() / restart().
Telemetry on lifecycle transitions.Subscribe to manager via <GristHandshakeProvider>.

The default surface (useGrist() and friends) already runs the full FSM under the hood since 0.2.0 — useGrist().status is a projection of the same snapshot. These hooks just expose the fuller story.

<GristHandshakeProvider>

Mount this once if you want a single shared snapshot across many descendants. Without it, every call to useGristHandshake() spawns its own manager — fine for a leaf widget, wasteful when several siblings need the same data.

import { GristHandshakeProvider, useGristHandshakeContext } from "grist-widget-sdk/advanced"

<GristHandshakeProvider options={{ requiredAccess: "read table" }}>
  <App />
</GristHandshakeProvider>

function App() {
  const { snapshot, capabilities, reload } = useGristHandshakeContext()
  // …
  return null
}

Props

type GristHandshakeProviderProps = {
  options?: UseGristHandshakeOptions
  children: React.ReactNode
}

type UseGristHandshakeOptions = GristReadyOptions & {
  detect?: DetectOptions
  negotiate?: NegotiateOptions
  mappings?: MappingsOptions
  /** `false` disables the heartbeat entirely. */
  heartbeat?: HeartbeatOptions | false
}

Coexists with <GristWidgetProvider> — both providers can be mounted in the same tree without interference. They share the page-level ensureGristReady() singleton so grist.ready is invoked once per page.

useGristHandshake(options?)

Standalone hook (no provider needed). Spawns a manager and subscribes to its snapshot via useSyncExternalStore.

const {
  snapshot,
  status,
  error,
  capabilities,
  reload,
  restart,
} = useGristHandshake({ requiredAccess: "read table" })

Returns:

type UseGristHandshakeResult = {
  snapshot: GristWidgetSnapshot
  status: GristWidgetStatus               // booting | ready | unavailable | error
  error: string | null
  capabilities: GristCapabilities
  /** Soft retry: bumps generation, re-runs detect → negotiate. */
  reload: () => void
  /** Hard reload: triggers `window.location.reload()` in browsers. */
  restart: () => void
}

Inside a provider

const { snapshot, capabilities } = useGristHandshakeContext()       // throws if no provider
const ctx = useGristHandshakeContextOptional()                       // returns null if no provider

useGristHandshakeContext() exposes the same shape as the standalone hook plus the manager's recordRpcSuccess() / recordRpcFailure() hooks (useful when integrating with a custom RPC layer outside the SDK's slice hooks — see Heartbeat coalescence).

useGristCapabilities(options?)

Convenience hook that returns only the derived capabilities slice. Equivalent to useGristHandshake(options).capabilities but stable across snapshot changes that don't affect capabilities.

const { canRead, canRender, canWriteRecords, canWriteSchema } = useGristCapabilities()

if (!canRender) return <Skeleton />
return canWriteRecords ? <Editable /> : <ReadOnly />

Prop

Type

hasUsableMappings, missingMappings, and emptyMultipleMappings mirror the same mapping state useGrist().columnMappingStatus exposes — see ValidateColumnMappingsResult.

Capabilities form a conjunctive chain — canWriteSchema ⇒ canWriteRecords ⇒ canRender ⇒ canRead. The chain is asserted by property tests in tests/unit/handshake-properties.test.ts.

The snapshot

Prop

Type

Lifecycle

Prop

Type

Prop

Type

terminated is absorbing — no action can revive it without a reload().

state is GristLinkState: "connected" | "stale" | "lost" — there is no "unknown" link state; before the first successful RPC the link simply hasn't been observed yet (lastSuccessMs: null).

The heartbeat moves the link through connected → stale → lost based on missed probes (see Heartbeat below). lost escalates to global status === "error".

Config / mappings

Prop

Type

Prop

Type

The pending state lets a widget render a "Configuring mappings…" UI distinct from booting. unreported fires after a timeout without any section-API / stream payload — surface a friendly "host did not report mappings" hint and keep the rest of the widget operable. invalidating holds the previous complete/incomplete payload while a fresh one is awaited, so the UI doesn't flicker back to an empty state.

Sync

Prop

Type

Prop

Type

A stream is hot while events arrive within budget, warming before the first event, cold from the initial snapshot, and stale once the freshness budget expires (see the reason field for why).

Heartbeat coalescence

The manager keeps the channel alive with a periodic probe (default 30 s, calls grist.docApi.getDocName()). Every successful Grist RPC the SDK issues through its slice hooks is reported back to the manager — the heartbeat then skips the next scheduled probe because the natural traffic already proved the link is healthy.

This is automatic when you mount <GristWidgetProvider>:

const writes = useGristWrites()
await writes.applyActions([["UpdateRecord", "Tasks", 1, { Done: true }]])
// → manager.recordRpcSuccess() fired internally, next heartbeat probe deferred.

Failures call recordRpcFailure() instead, which shortens the next probe to ≤ 1 s for fast re-confirmation. The single round-trip cost of a false-positive (e.g. a semantic validation error misreported as a transport failure) is the simplicity tax — we deliberately don't try to distinguish error categories at this layer.

Manual coalescence

If you talk to Grist outside the SDK (e.g. raw grist.docApi.* calls or your own REST layer), you can feed the manager yourself:

const { recordRpcSuccess, recordRpcFailure } = useGristHandshakeContext()

try {
  const result = await myCustomRpc()
  recordRpcSuccess()
  // …
} catch (err) {
  recordRpcFailure()
  throw err
}

Disabling the heartbeat

For widgets that are essentially write-only and never want a background probe:

<GristHandshakeProvider options={{ heartbeat: false }}>

The link state then stays in unknown and canRender / canWriteRecords gate on lifecycle.phase === "online" only.

reload() vs restart()

CallWhat it does
reload()Bumps the snapshot generation, cancels in-flight effects, restarts detect → negotiate. Stale callbacks from the previous generation are dropped. Clears the ensureGristReady singleton so a fresh grist.ready is issued.
restart()Calls reload(), then window.location.reload() (browser only — no-op in tests).

Both leave the page's host realm untouched — neither tears down the iframe.

Generation discipline

The snapshot carries a monotonically increasing generation. Every effect dispatch carries the generation it was scheduled with; the reducer drops actions whose generation is older than the current one. This guarantees that a slow grist.ready resolving after a reload() can never restore an obsolete online phase.

Action-builders that talk to Grist outside the SDK should read snapshot.generation once before sending and compare on return — the manager's own internal effects already do this.

On this page