Skip to content

Design principles

These are the rules we use when discussing changes to the API surface. The principles take precedence over individual feature requests: when a request would violate one, we either change the request or, very rarely, change the principle.

1. One default surface

The 90% case is one provider + one boundary + one hook:

tsx
<GristWidgetProvider options={...}>
  <GristBoundary>
    <App />        {/* useGrist() */}
  </GristBoundary>
</GristWidgetProvider>

Every feature added to the SDK must answer: how does this look from useGrist()? If a feature can't be reached from there, it shouldn't be in the primary surface.

Implication: avoid framework-style "register a plugin" patterns. Avoid additional providers (GristThemeProvider, GristAuthProvider, …). They're tempting but split the mental model.

2. No leaky globals

Widget code never imports the grist global. The SDK calls grist.ready exactly once and owns all event subscriptions. This makes:

  • Tests deterministic — the emulator can install window.grist once and every hook sees the same state.
  • Multiple component trees safe — nothing fights over the global.
  • Hosts swappable — if Grist ships a new RPC channel, we change it in one file.

If a feature requires touching the grist global from user code, propose a wrapper in useGrist() instead.

3. Sane decoding defaults

Subscriptions and section-bound fetches return decoded values. Date columns return Date, ref columns return ref-shaped objects, errors return error-shaped objects. The raw columnar fetchTable returns wire values because that's its contract with Grist.

Implication: the documentation talks about JS-native shapes by default. We only mention ["D", epoch, tz] in helper docs.

4. Mode over flags

mode: "empty" | "row" | "new-row" is preferred over record == null && isNewRecord == false boolean combinatorics. Renders branch on mode, not on derived predicates. We accept the mild redundancy of also exposing isNewRecord for terseness in conditionals.

Future state additions go through mode first. If two new states are mutually exclusive with existing ones, prefer extending mode over a new flag.

5. Action paths are stable

Two write paths, and only two:

  • TableOperations (w.table.*, w.getTable(id).*) for record CRUD.
  • w.applyActions([builders]) for everything else.

We don't ship a third (e.g. "form-binding hook"). Higher-level patterns should be implemented in user code or in a separate package, never inside useGrist().

6. Slice hooks for performance only

Slice hooks (useGristStatus, useGristSelection, useGristWrites, useGristTheme) exist only to limit re-renders in big trees. They never expose data that's not available on useGrist() itself. The boundary between slices is "what re-renders together", not "what's logically grouped".

Implication: don't add a new slice hook unless profiling on a real widget shows render cost.

7. Mapping is a first-class concern

Mapping is not a "feature toggle" — it's the default way widgets relate to data. Every read that surfaces logical names must have a corresponding write reverse-map. Every error path that includes a missing mapping must include the missing column names.

8. Tests look like production

We don't ship a "mock mode" that bypasses internal logic. Tests use the emulator, which exercises the real subscription paths. If a hook silently behaves differently in tests, it's a bug.

Implication: changes to the SDK's internal subscription / merging logic must be reflected in the emulator, not papered over with test-only shims.

9. Types as part of the API

A type-only change is still a breaking change. We version generics as carefully as we version runtime behavior. useGrist<TRow, TMapped> is part of the surface — narrowing its inputs or relaxing its outputs is an API change.

10. One package, two entry points

Everything ships from a single npm package. We expose two extra subpaths:

  • grist-widget-sdk/advanced — opt-in lower-level hooks.
  • grist-widget-sdk/emulator (and /emulator/testing) — tree-shakable test/emulator code.

We won't fragment the package further (no @grist/react, @grist/core, etc.). When a feature needs a separate dependency (e.g. Zod for runtime validation), it joins this package.

11. Errors are user-visible

Connection errors, action errors, mapping errors all surface as plain strings on useGrist(). They are never thrown silently or logged-only. <GristBoundary> and getGristSdkAlertDescriptors(w) give widgets a one-line path to a good UX.

12. Documentation is a contract

The pages under /guide, /api, and /design describe the target SDK. PRs that change behavior without updating docs are incomplete. PRs that introduce undocumented features are incomplete.

This documentation site is the single source of truth for what the SDK should look like; the implementation tracks it.

Conventions

The principles above explain why the SDK is shaped the way it is. The conventions below are the surface-level rules that follow from them — the patterns every public symbol is expected to obey. They are auditable: any new export that breaks one of these should land with a deliberate explanation in the PR.

Naming conventions

  • Hooks: useGrist... (e.g. useGrist, useGristSelection, useGristSchema).
  • Components: Grist... (e.g. GristWidgetProvider, GristBoundary).
  • Types: Grist.... Hook return types use UseGrist...Result (e.g. UseGristResult, UseGristSchemaResult).
  • Action builders: grist<verb>...Action (e.g. gristAddColumnAction, gristBulkRemoveRecordAction).
  • Helpers: descriptive snake-cased verbs prefixed with the domain (decodeGristValue, fetchAttachmentBlob, extractGristAttachmentIdsFromCell).

Slot conventions

  • Boolean inputs default to the safe value: requiredAccess defaults to "read table"; logWhenNotEmbedded defaults to false.
  • Numbers default to documented constants (unavailableGraceMs = 5000, etc.). The constants are stable across patch versions.
  • options.readOnly is the universal "low-privilege" flag for REST / attachment helpers.

Empty / null conventions

The SDK distinguishes three "no value" states deliberately:

  • null — "not yet known" (pre-emit, pre-ready).
  • [] — "empty result" (e.g. zero rows in section).
  • undefined — "user didn't pass this". The SDK never returnsundefined; it only accepts it on input options.

This rule lets mappedRecord == null mean "missing mappings" and records == [] mean "no rows in section", with no ambiguity.

Promise vs sync conventions

  • Anything that touches Grist is async and returns a Promise.
  • Anything purely React (slice hooks, component props, computed derivations on useGrist()) is sync.
  • Action builders (gristAddColumnAction, etc.) are sync — they only construct user-action tuples for applyActions to send.

Monorepo layout

The repository uses pnpm workspaces and Turborepo to coordinate multiple packages:

  • packages/core/ — the published SDK package grist-widget-sdk. Library build via tsup, not Vite. Public entry at packages/core/src/sdk/index.ts; emulator subpath at packages/core/src/emulator/index.ts.
  • apps/playground/ — React + Vite app used to develop the SDK and host the live demo widgets at demo.grist-widgets.com. Depends on grist-widget-sdk via workspace:^.
  • apps/docs/ — this VitePress documentation site. The /guide/, /api/, and /design/ trees are developer-facing; the /files/ tree is the agent operating manual.
  • templates/grist-widget-template-vite/ — standalone Vite starter pasteable via degit. Also references grist-widget-sdk via workspace:^ while inside the monorepo.
  • widgets/ (at repo root) — a separate widget track being exported out of this monorepo; owned by its own deploy workflow (.github/workflows/deploy-v0-minimal-demo.yml). Not part of the SDK.

Widget runtime flow:

text
Grist host document
  → iframe widget app
    → grist-plugin-api global
      → grist-widget-sdk hooks / providers
        → widget React components

turbo.json defines build with dependsOn: ["^build"], so building the playground or docs first builds packages/core.

Anti-principles (things we explicitly don't do)

  • We don't auto-detect column types. The user maps logical names to real columns; we don't guess.
  • We don't ship UI styles (only <GristBoundary> chrome with overridable appearance).
  • We don't expose internal slice contexts. Slice hooks are the public surface; the contexts behind them are internal.
  • We don't try to support non-React frameworks. Use grist-plugin-api.js directly.
  • We don't shim across Grist versions. If a method isn't there, calls throw — we surface that to the user, we don't fall back silently.

Released under the ISC License.