Skip to content

Cheat sheet

One page for daily reference once you understand the mental model. Every TypeScript example below is the full WidgetApp.tsx from a playground widget — type-checked by pnpm --filter playground build:widgets and runnable at https://demo.grist-widgets.com/?url=widget.html?id=<id>.

The playground widget.html shell adds GristWidgetProvider and GristBoundary; snippets show only WidgetApp (and GRIST_OPTIONS).

Provider + hook (selection modes)

hello-world

tsx
import { useGrist, type UseGristOptions } from "grist-widget-sdk"

export const GRIST_OPTIONS: UseGristOptions = {
  requiredAccess: "read table",
}

export function WidgetApp() {
  const w = useGrist()
  const rowKey =
    w.record && typeof w.record.id === "number" ? String(w.record.id) : w.mode

  if (w.mode === "empty") return <p>Select a row.</p>
  if (w.mode === "new-row") return <p>New row flow</p>
  return <p key={rowKey}>Selected row #{String(w.record!.id)}</p>
}
w.modeMeaning
"booting"Plugin API not ready yet. <GristBoundary> handles this for you.
"empty"The Custom Widget section has no selected row.
"row"A real row is selected. w.record and w.mappedRecord are populated.
"new-row"The user is in the new-row flow. w.isNewRecord === true.

Mappings + column gate

form-editmappedRecord, mapBack, columnMappingStatus, and writes:

tsx
import { useState, type FormEvent, type ReactNode } from "react"

import {
  useGrist,
  useWidgetMetadata,
  type UseGristOptions,
  type UseGristResult,
} from "grist-widget-sdk"

import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

export const GRIST_OPTIONS: UseGristOptions = {
  requiredAccess: "full",
  columns: [
    { name: "Title", type: "Text" },
    { name: "Description", type: "Text", optional: true },
    { name: "Done", type: "Bool" },
    { name: "Priority", type: "Text", optional: true },
  ],
}

export const WIDGET_METADATA = {
  title: "Row editor",
  description:
    "Edit the selected row in a polished form. Showcases column mappings, write status, and error UI.",
} as const

type FormEditMappedRecord = {
  Title?: unknown
  Description?: unknown
  Done?: unknown
  Priority?: unknown
}

type FormEditGrist = UseGristResult<Record<string, unknown>, FormEditMappedRecord>

type Draft = {
  title: string
  description: string
  done: boolean
  priority: string
}

function readDraft(mapped: FormEditMappedRecord | null): Draft {
  return {
    title:
      typeof mapped?.Title === "string"
        ? mapped.Title
        : mapped?.Title == null
          ? ""
          : String(mapped.Title),
    description:
      typeof mapped?.Description === "string"
        ? mapped.Description
        : mapped?.Description == null
          ? ""
          : String(mapped.Description),
    done: mapped?.Done === true,
    priority:
      typeof mapped?.Priority === "string"
        ? mapped.Priority
        : mapped?.Priority == null
          ? ""
          : String(mapped.Priority),
  }
}

export function WidgetApp() {
  useWidgetMetadata(WIDGET_METADATA)
  const w = useGrist<Record<string, unknown>, FormEditMappedRecord>()

  if (w.status === "booting") {
    return <Empty title="Connecting to Grist…" body="" />
  }

  if (!w.columnMappingStatus.ok) {
    return (
      <Empty
        title="Missing column mappings"
        body="Open the widget configuration panel and map the four required columns: Title, Done, Description (optional), Priority (optional)."
      />
    )
  }

  if (w.mode === "empty") {
    return (
      <Empty
        title="No row selected"
        body="Select a row in the host Grist section to edit it here."
      />
    )
  }

  return <RowEditor key={String(w.record!.id)} w={w} />
}

function RowEditor({ w }: { w: FormEditGrist }) {
  const [draft, setDraft] = useState<Draft>(() => readDraft(w.mappedRecord))
  const [saveError, setSaveError] = useState<string | null>(null)

  async function save(e: FormEvent) {
    e.preventDefault()
    if (!w.record) return
    setSaveError(null)
    try {
      await w.table.update({
        id: w.record.id as number,
        fields: w.mapBack({
          Title: draft.title,
          Description: draft.description || null,
          Done: draft.done,
          Priority: draft.priority || null,
        }),
      })
    } catch (err) {
      setSaveError(err instanceof Error ? err.message : String(err))
    }
  }

  return (
    <form
      onSubmit={save}
      className="flex h-svh min-h-0 flex-col gap-4 overflow-auto p-6"
    >
      <header>
        <h1 className="text-lg font-medium">Row #{String(w.record!.id)}</h1>
        <p className="text-sm text-muted-foreground">
          Edit fields, then save back to Grist via{" "}
          <code className="rounded bg-muted px-1 text-xs">table.update</code>.
        </p>
      </header>

      <Field label="Title">
        <Input
          required
          value={draft.title}
          onChange={(e) =>
            setDraft((d) => ({ ...d, title: e.target.value }))
          }
        />
      </Field>

      <Field label="Description">
        <Input
          value={draft.description}
          onChange={(e) =>
            setDraft((d) => ({ ...d, description: e.target.value }))
          }
        />
      </Field>

      <Field label="Priority">
        <Input
          placeholder="High / Medium / Low"
          value={draft.priority}
          onChange={(e) =>
            setDraft((d) => ({ ...d, priority: e.target.value }))
          }
        />
      </Field>

      <label className="flex items-center gap-2 text-sm">
        <input
          type="checkbox"
          className="size-4 rounded border-input"
          checked={draft.done}
          onChange={(e) =>
            setDraft((d) => ({ ...d, done: e.target.checked }))
          }
        />
        <span>Mark as done</span>
      </label>

      {saveError ? (
        <Alert variant="destructive">
          <AlertTitle>Save failed</AlertTitle>
          <AlertDescription>{saveError}</AlertDescription>
        </Alert>
      ) : null}

      <div className="mt-auto flex items-center gap-2 border-t pt-3">
        <Button type="submit" disabled={w.actionStatus === "running"}>
          {w.actionStatus === "running" ? "Saving…" : "Save"}
        </Button>
        <Button
          type="button"
          variant="outline"
          onClick={() => setDraft(readDraft(w.mappedRecord))}
          disabled={w.actionStatus === "running"}
        >
          Reset
        </Button>
        {w.actionStatus === "error" && w.actionError ? (
          <span className="text-xs text-destructive">{w.actionError}</span>
        ) : null}
      </div>
    </form>
  )
}

function Field({ label, children }: { label: string; children: ReactNode }) {
  return (
    <label className="flex flex-col gap-1">
      <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
        {label}
      </span>
      {children}
    </label>
  )
}

function Empty({ title, body }: { title: string; body: string }) {
  return (
    <div className="flex h-svh items-center justify-center p-6">
      <div className="max-w-sm text-center">
        <h2 className="font-medium">{title}</h2>
        {body ? (
          <p className="mt-2 text-sm text-muted-foreground">{body}</p>
        ) : null}
      </div>
    </div>
  )
}

Declared columns only (mapping panel):

declared-columns

tsx
import {
  useGrist,
  useWidgetMetadata,
  type UseGristOptions,
} from "grist-widget-sdk"

export const GRIST_OPTIONS: UseGristOptions = {
  requiredAccess: "full",
  columns: [
    { name: "Title", type: "Text" },
    { name: "Done", type: "Bool" },
    { name: "Tags", type: "ChoiceList", allowMultiple: true, optional: true },
  ],
}

export const WIDGET_METADATA = {
  title: "Declared columns",
  description:
    "Shows GRIST_OPTIONS column declarations and mappedRecord after the user maps columns.",
} as const

export function WidgetApp() {
  useWidgetMetadata(WIDGET_METADATA)
  const w = useGrist()

  if (!w.columnMappingStatus.ok) {
    return (
      <p className="p-4 text-sm">
        Map columns in the widget panel:{" "}
        {w.columnMappingStatus.missing.join(", ")}
      </p>
    )
  }

  if (w.mode === "empty") return <p className="p-4 text-sm">Select a row.</p>

  return (
    <pre className="overflow-auto p-4 text-xs">
      {JSON.stringify(w.mappedRecord, null, 2)}
    </pre>
  )
}

Writes

writes-democreate, update, upsert, destroy (single or array via table):

tsx
import {
  useGrist,
  useWidgetMetadata,
  type UseGristOptions,
} from "grist-widget-sdk"

import { Button } from "@/components/ui/button"

export const GRIST_OPTIONS: UseGristOptions = {
  requiredAccess: "full",
  columns: [{ name: "Title", type: "Text" }],
}

export const WIDGET_METADATA = {
  title: "Table writes",
  description: "create, update, upsert, destroy via w.table.",
} as const

export function WidgetApp() {
  useWidgetMetadata(WIDGET_METADATA)
  const w = useGrist()

  if (!w.columnMappingStatus.ok) {
    return <p className="p-4 text-sm">Map the Title column.</p>
  }

  return (
    <div className="flex flex-wrap gap-2 p-4">
      <Button
        type="button"
        onClick={() =>
          void w.table.create({ fields: w.mapBack({ Title: "New" }) })
        }
      >
        create
      </Button>
      <Button
        type="button"
        disabled={!w.record}
        onClick={() => {
          if (!w.record) return
          void w.table.update({
            id: w.record.id as number,
            fields: w.mapBack({ Title: "Updated" }),
          })
        }}
      >
        update
      </Button>
      <Button
        type="button"
        onClick={() =>
          void w.table.upsert({
            require: w.mapBack({ Title: "Existing" }),
            fields: w.mapBack({ Title: "Existing" }),
          })
        }
      >
        upsert
      </Button>
      <Button
        type="button"
        disabled={!w.records?.length}
        onClick={() => {
          const ids = (w.records ?? [])
            .slice(0, 3)
            .map((r) => r.id as number)
          if (ids.length) void w.table.destroy(ids)
        }}
      >
        destroy (first 3)
      </Button>
    </div>
  )
}

Single-row update:

mark-done

tsx
import {
  useGrist,
  useWidgetMetadata,
  type UseGristOptions,
} from "grist-widget-sdk"

import { Button } from "@/components/ui/button"

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

export const WIDGET_METADATA = {
  title: "Mark done",
  description: "Single-row write via table.update.",
} as const

export function WidgetApp() {
  useWidgetMetadata(WIDGET_METADATA)
  const w = useGrist()

  if (!w.columnMappingStatus.ok) {
    return <p className="p-4 text-sm">Map the Done column.</p>
  }
  if (w.mode !== "row" || !w.record) {
    return <p className="p-4 text-sm">Select a row.</p>
  }

  async function onClick() {
    await w.table.update({
      id: w.record!.id as number,
      fields: w.mapBack({ Done: true }),
    })
  }

  return (
    <div className="p-4">
      <Button type="button" onClick={() => void onClick()}>
        Mark done
      </Button>
    </div>
  )
}

Schema mutations

schema-migration

tsx
import {
  gristAddVisibleColumnAction,
  gristRemoveColumnAction,
  gristRenameColumnAction,
  useGrist,
  useWidgetMetadata,
  type UseGristOptions,
} from "grist-widget-sdk"

import { Button } from "@/components/ui/button"

export const GRIST_OPTIONS: UseGristOptions = {
  requiredAccess: "full",
}

export const WIDGET_METADATA = {
  title: "Schema migration",
  description:
    "applyActions with gristAddVisibleColumnAction, gristRenameColumnAction, and gristRemoveColumnAction.",
} as const

export function WidgetApp() {
  useWidgetMetadata(WIDGET_METADATA)
  const w = useGrist()

  async function runMigration() {
    const tableId = w.currentTableId
    if (!tableId) return
    await w.applyActions(
      [
        gristAddVisibleColumnAction(tableId, "Priority", { type: "Choice" }),
        gristAddVisibleColumnAction(tableId, "DueAt", { type: "Date" }),
        gristRenameColumnAction(tableId, "DueAt", "DueDate"),
      ],
      { desc: "Add Priority + DueAt; rename DueAt → DueDate" },
    )
  }

  async function rollback() {
    const tableId = w.currentTableId
    if (!tableId) return
    await w.applyActions(
      [
        gristRemoveColumnAction(tableId, "Priority"),
        gristRemoveColumnAction(tableId, "DueDate"),
      ],
      { desc: "Remove Priority + DueDate" },
    )
  }

  return (
    <div className="flex gap-2 p-4">
      <Button type="button" onClick={() => void runMigration()}>
        Run migration
      </Button>
      <Button type="button" variant="destructive" onClick={() => void rollback()}>
        Rollback
      </Button>
      {w.actionStatus === "running" ? (
        <p className="mt-2 text-xs text-muted-foreground">Applying…</p>
      ) : null}
      {w.actionError ? (
        <p className="mt-2 text-xs text-destructive">{w.actionError}</p>
      ) : null}
    </div>
  )
}

Reads

reads-demo

tsx
import { useEffect, useState } from "react"

import {
  useGrist,
  useWidgetMetadata,
  type UseGristOptions,
} from "grist-widget-sdk"

export const GRIST_OPTIONS: UseGristOptions = {
  requiredAccess: "read table",
}

export const WIDGET_METADATA = {
  title: "Reads demo",
  description: "listTables, fetchTableRows, fetchRow, fetchSelectedRecord.",
} as const

export function WidgetApp() {
  useWidgetMetadata(WIDGET_METADATA)
  const {
    isReady,
    currentTableId,
    record,
    listTables,
    fetchTableRows,
    fetchRow,
    fetchSelectedRecord,
  } = useGrist()
  const [summary, setSummary] = useState<string>("Loading…")

  useEffect(() => {
    if (!isReady) return
    let cancelled = false
    void (async () => {
      const tableIds = await listTables()
      const tableId = currentTableId ?? tableIds[0]
      if (!tableId) return
      const rows = await fetchTableRows(tableId)
      const single =
        rows[0]?.id != null ? await fetchRow(tableId, rows[0].id as number) : null
      const here =
        record?.id != null ? await fetchSelectedRecord(record.id as number) : null
      if (!cancelled) {
        setSummary(
          JSON.stringify(
            { tableIds, rowCount: rows.length, single, here },
            null,
            2,
          ),
        )
      }
    })()
    return () => {
      cancelled = true
    }
  }, [
    isReady,
    currentTableId,
    record?.id,
    listTables,
    fetchTableRows,
    fetchRow,
    fetchSelectedRecord,
  ])

  return (
    <pre className="max-h-svh overflow-auto p-4 text-xs">{summary}</pre>
  )
}

Safe-parse (Zod-style cell validation)

safe-parse

tsx
import { useEffect, useState } from "react"

import {
  safeParseGristTableData,
  useGrist,
  useWidgetMetadata,
  type UseGristOptions,
} from "grist-widget-sdk"

type TaskRow = { Title: string; Due: Date | null }

export const GRIST_OPTIONS: UseGristOptions = {
  requiredAccess: "read table",
  suppressAlerts: ["section-not-linked"],
}

export const WIDGET_METADATA = {
  title: "Safe parse",
  description: "safeParseGristTableData with per-cell issues.",
} as const

export function WidgetApp() {
  useWidgetMetadata(WIDGET_METADATA)
  const { isReady, currentTableId, fetchTable } = useGrist()
  const [rows, setRows] = useState<TaskRow[]>([])
  const [issues, setIssues] = useState<unknown>(null)

  useEffect(() => {
    if (!isReady) return
    let cancelled = false
    void (async () => {
      const tableId = currentTableId
      if (!tableId) return
      const columnar = await fetchTable(tableId)
      const parsed = safeParseGristTableData<TaskRow>(columnar, {
        columns: {
          Title: { type: "Text" },
          Due: { type: "Date" },
        },
      })
      if (!cancelled) {
        setRows(parsed.rows)
        setIssues(parsed.cellIssues)
      }
    })()
    return () => {
      cancelled = true
    }
  }, [isReady, currentTableId, fetchTable])

  return (
    <div className="grid gap-2 p-4 text-sm">
      <p>{rows.length} parsed row(s)</p>
      {issues ? (
        <pre className="overflow-auto text-xs">{JSON.stringify(issues, null, 2)}</pre>
      ) : (
        <p className="text-muted-foreground">No cell issues.</p>
      )}
    </div>
  )
}

Attachments

attachment-gallery

tsx
import { useEffect, useState } from "react"

import {
  extractGristAttachmentIdsFromCell,
  useGrist,
  useWidgetMetadata,
  type UseGristOptions,
} from "grist-widget-sdk"

import { Button } from "@/components/ui/button"

export const GRIST_OPTIONS: UseGristOptions = {
  requiredAccess: "full",
  columns: [{ name: "Attachments", type: "Attachments" }],
}

export const WIDGET_METADATA = {
  title: "Attachment gallery",
  description:
    "Thumbnail grid of every attachment in the selected row. Showcases extractGristAttachmentIdsFromCell + useGrist().fetchAttachmentBlob.",
} as const

type Thumb = {
  id: string
  url: string
  contentType: string
}

export function WidgetApp() {
  useWidgetMetadata(WIDGET_METADATA)
  const w = useGrist()

  const cellValue = w.mappedRecord?.Attachments ?? null
  const ids = extractGristAttachmentIdsFromCell(cellValue)
  const idsKey = ids.join(",")

  if (w.status === "booting") {
    return <Empty title="Connecting to Grist…" body="" />
  }

  if (!w.columnMappingStatus.ok) {
    return (
      <Empty
        title="Missing column mapping"
        body="Open the widget configuration panel and map the Attachments column."
      />
    )
  }

  if (w.mode === "empty") {
    return (
      <Empty
        title="No row selected"
        body="Select a row in the host Grist section to see its attachments."
      />
    )
  }

  if (ids.length === 0) {
    return (
      <Empty
        title="No attachments in this row"
        body="The mapped Attachments column is empty for the selected row."
      />
    )
  }

  const rowKey = `${String(w.record?.id ?? "")}:${idsKey}`

  return (
    <AttachmentGrid
      key={rowKey}
      ids={ids}
      recordId={w.record?.id}
      fetchAttachmentBlob={w.fetchAttachmentBlob}
    />
  )
}

function AttachmentGrid({
  ids,
  recordId,
  fetchAttachmentBlob,
}: {
  ids: string[]
  recordId: unknown
  fetchAttachmentBlob: ReturnType<typeof useGrist>["fetchAttachmentBlob"]
}) {
  const [thumbs, setThumbs] = useState<Thumb[]>([])
  const [error, setError] = useState<string | null>(null)
  const [loading, setLoading] = useState(true)

  // Stable string key so the effect doesn't re-fire when the parent
  // re-renders with a structurally identical but referentially new array.
  const idsKey = ids.join(",")

  useEffect(() => {
    const currentIds = idsKey ? idsKey.split(",") : []
    let cancelled = false
    const objectUrls: string[] = []
    void (async () => {
      try {
        const next: Thumb[] = []
        for (const id of currentIds) {
          const fetched = await fetchAttachmentBlob(id)
          if (cancelled) return
          const url = URL.createObjectURL(fetched.blob)
          objectUrls.push(url)
          next.push({
            id,
            url,
            contentType: fetched.contentType,
          })
        }
        if (!cancelled) setThumbs(next)
      } catch (err) {
        if (!cancelled)
          setError(err instanceof Error ? err.message : String(err))
      } finally {
        if (!cancelled) setLoading(false)
      }
    })()
    return () => {
      cancelled = true
      for (const u of objectUrls) URL.revokeObjectURL(u)
    }
  }, [idsKey, fetchAttachmentBlob])

  return (
    <div className="flex h-svh min-h-0 flex-col gap-3 p-4">
      <header>
        <h1 className="text-lg font-medium">Attachments</h1>
        <p className="text-sm text-muted-foreground">
          {ids.length} attachment{ids.length === 1 ? "" : "s"} on row{" "}
          <code className="rounded bg-muted px-1 text-xs">
            {String(recordId ?? "?")}
          </code>
        </p>
      </header>

      {error ? (
        <p className="rounded border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
          {error}
        </p>
      ) : null}

      {loading ? (
        <p className="text-xs text-muted-foreground">Loading thumbnails…</p>
      ) : null}

      <div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-auto sm:grid-cols-3 lg:grid-cols-4">
        {thumbs.map((thumb) => (
          <Thumbnail key={thumb.id} thumb={thumb} />
        ))}
      </div>
    </div>
  )
}

function Thumbnail({ thumb }: { thumb: Thumb }) {
  const isImage = thumb.contentType.startsWith("image/")
  const label = `attachment-${thumb.id}`

  return (
    <figure className="group flex flex-col gap-2 rounded-lg border bg-card p-2">
      <div className="flex aspect-square items-center justify-center overflow-hidden rounded bg-muted">
        {isImage ? (
          <img
            src={thumb.url}
            alt={label}
            className="size-full object-cover"
          />
        ) : (
          <span className="text-xs text-muted-foreground">
            {thumb.contentType || "binary"}
          </span>
        )}
      </div>
      <figcaption className="flex items-center justify-between gap-2">
        <span className="truncate text-xs">{label}</span>
        <Button asChild variant="outline" size="sm">
          <a href={thumb.url} download={label}>
            Save
          </a>
        </Button>
      </figcaption>
    </figure>
  )
}

function Empty({ title, body }: { title: string; body: string }) {
  return (
    <div className="flex h-svh items-center justify-center p-6">
      <div className="max-w-sm text-center">
        <h2 className="font-medium">{title}</h2>
        {body ? (
          <p className="mt-2 text-sm text-muted-foreground">{body}</p>
        ) : null}
      </div>
    </div>
  )
}

REST

rest-fetch

tsx
import { useState } from "react"

import {
  useGrist,
  useWidgetMetadata,
  type UseGristOptions,
} from "grist-widget-sdk"

import { Button } from "@/components/ui/button"

export const GRIST_OPTIONS: UseGristOptions = {
  requiredAccess: "full",
  suppressAlerts: ["section-not-linked"],
}

export const WIDGET_METADATA = {
  title: "REST fetch",
  description: "fetchWithAuth against the Grist REST API.",
} as const

export function WidgetApp() {
  useWidgetMetadata(WIDGET_METADATA)
  const w = useGrist()
  const [body, setBody] = useState<string | null>(null)
  const [error, setError] = useState<string | null>(null)

  async function loadTables() {
    setError(null)
    try {
      const res = await w.fetchWithAuth("/tables")
      const json = await res.json()
      setBody(JSON.stringify(json, null, 2))
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err))
    }
  }

  return (
    <div className="flex flex-col gap-2 p-4">
      <Button type="button" onClick={() => void loadTables()}>
        GET /tables
      </Button>
      {error ? <p className="text-xs text-destructive">{error}</p> : null}
      {body ? (
        <pre className="max-h-64 overflow-auto text-xs">{body}</pre>
      ) : null}
    </div>
  )
}

Theme

theme-demo

tsx
import {
  useGrist,
  useWidgetMetadata,
  type UseGristOptions,
} from "grist-widget-sdk"

export const GRIST_OPTIONS: UseGristOptions = {
  requiredAccess: "read table",
  suppressAlerts: ["section-not-linked"],
}

export const WIDGET_METADATA = {
  title: "Theme",
  description: "Read w.theme from the host document.",
} as const

export function WidgetApp() {
  useWidgetMetadata(WIDGET_METADATA)
  const w = useGrist()

  return (
    <div className="p-4" data-theme={w.theme ?? "light"}>
      <p className="text-sm">Host theme: {w.theme ?? "light"}</p>
    </div>
  )
}

Linked sections

task-boardsetCursorPosition on card click:

tsx
import { useMemo, useState, type DragEvent } from "react"

import {
  useGrist,
  useWidgetMetadata,
  type UseGristOptions,
} from "grist-widget-sdk"

import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"

export const GRIST_OPTIONS: UseGristOptions = {
  requiredAccess: "full",
  allowSelectBy: true,
  columns: [
    { name: "Title", type: "Text" },
    { name: "Status", type: "Text" },
    { name: "Priority", type: "Text", optional: true },
  ],
}

export const WIDGET_METADATA = {
  title: "Task board",
  description:
    "Mini Kanban board grouped by Status. Drag a card to update Status; click to focus the row in linked sections.",
} as const

const DEFAULT_STATUSES = ["To do", "Doing", "Done"] as const

type Task = {
  id: number
  title: string
  status: string
  priority: string | null
}

function pickStatuses(rows: readonly Task[]): readonly string[] {
  const seen = new Set<string>(DEFAULT_STATUSES)
  for (const r of rows) {
    if (r.status && !seen.has(r.status)) seen.add(r.status)
  }
  return [...seen]
}

function asString(value: unknown): string {
  if (typeof value === "string") return value
  if (value == null) return ""
  return String(value)
}

export function WidgetApp() {
  useWidgetMetadata(WIDGET_METADATA)
  const w = useGrist()

  // Resolve the physical column ids once per render. We can't call mapBack on
  // every row record because mapBack is for a single record write; here we
  // want to project the rows from raw `records` into a typed shape.
  const titleColId = w.resolveMappedColumnId("Title")
  const statusColId = w.resolveMappedColumnId("Status")
  const priorityColId = w.resolveMappedColumnId("Priority")

  const tasks = useMemo<Task[]>(() => {
    if (
      !w.columnMappingStatus.ok ||
      !titleColId ||
      !statusColId ||
      !w.records
    ) {
      return []
    }
    return w.records.map((row) => {
      const status = asString(row[statusColId])
      const priority = priorityColId ? asString(row[priorityColId]) : ""
      return {
        id: row.id as number,
        title: asString(row[titleColId]) || "(untitled)",
        status: status || "To do",
        priority: priority || null,
      }
    })
  }, [
    w.records,
    w.columnMappingStatus,
    titleColId,
    statusColId,
    priorityColId,
  ])

  const statuses = useMemo(() => pickStatuses(tasks), [tasks])
  const [dragId, setDragId] = useState<number | null>(null)

  if (w.status === "booting") {
    return <Empty title="Connecting to Grist…" body="" />
  }

  if (!w.columnMappingStatus.ok) {
    return (
      <Empty
        title="Missing column mappings"
        body="Open the widget configuration panel and map the columns: Title (Text), Status (Text), Priority (Text, optional)."
      />
    )
  }

  if (tasks.length === 0) {
    return (
      <Empty
        title="No rows to show"
        body="Add rows to the selected table — they will appear grouped by Status."
      />
    )
  }

  async function moveToStatus(task: Task, nextStatus: string) {
    if (task.status === nextStatus) return
    try {
      await w.table.update({
        id: task.id,
        fields: w.mapBack({ Status: nextStatus }),
      })
    } catch {
      // The card will snap back via the next `records` tick when the
      // optimistic update is rejected by Grist.
    }
  }

  return (
    <div className="flex h-svh min-h-0 flex-col gap-3 p-4">
      <header className="flex items-baseline justify-between">
        <h1 className="text-lg font-medium">Task board</h1>
        <span className="text-xs text-muted-foreground">
          {tasks.length} task{tasks.length === 1 ? "" : "s"}
        </span>
      </header>

      <div className="grid min-h-0 flex-1 grid-cols-1 gap-3 overflow-auto sm:grid-cols-2 lg:grid-cols-3">
        {statuses.map((status) => {
          const inStatus = tasks.filter((c) => c.status === status)
          return (
            <section
              key={status}
              className="flex min-h-32 flex-col gap-2 rounded-lg border bg-muted/40 p-3"
              onDragOver={(e) => {
                e.preventDefault()
                e.dataTransfer.dropEffect = "move"
              }}
              onDrop={(e) => {
                e.preventDefault()
                if (dragId == null) return
                const dragged = tasks.find((c) => c.id === dragId)
                setDragId(null)
                if (dragged) void moveToStatus(dragged, status)
              }}
            >
              <header className="flex items-center justify-between">
                <h2 className="text-sm font-semibold">{status}</h2>
                <Badge variant="secondary">{inStatus.length}</Badge>
              </header>
              <div className="flex flex-col gap-2">
                {inStatus.map((task) => (
                  <TaskCard
                    key={task.id}
                    task={task}
                    isSelected={w.record?.id === task.id}
                    onClick={() => void w.setCursorPosition({ rowId: task.id })}
                    onDragStart={() => setDragId(task.id)}
                    onDragEnd={() => setDragId(null)}
                  />
                ))}
                {inStatus.length === 0 ? (
                  <p className="text-xs text-muted-foreground">No tasks.</p>
                ) : null}
              </div>
            </section>
          )
        })}
      </div>
    </div>
  )
}

function TaskCard({
  task,
  isSelected,
  onClick,
  onDragStart,
  onDragEnd,
}: {
  task: Task
  isSelected: boolean
  onClick: () => void
  onDragStart: (e: DragEvent<HTMLDivElement>) => void
  onDragEnd: (e: DragEvent<HTMLDivElement>) => void
}) {
  return (
    <Card
      draggable
      onClick={onClick}
      onDragStart={onDragStart}
      onDragEnd={onDragEnd}
      className={
        "cursor-grab transition-shadow hover:shadow-md " +
        (isSelected ? "ring-2 ring-primary" : "")
      }
    >
      <CardContent className="flex flex-col gap-1 p-3">
        <span className="text-sm font-medium">{task.title}</span>
        {task.priority ? (
          <Badge variant="outline" className="self-start">
            {task.priority}
          </Badge>
        ) : null}
      </CardContent>
    </Card>
  )
}

function Empty({ title, body }: { title: string; body: string }) {
  return (
    <div className="flex h-svh items-center justify-center p-6">
      <div className="max-w-sm text-center">
        <h2 className="font-medium">{title}</h2>
        {body ? (
          <p className="mt-2 text-sm text-muted-foreground">{body}</p>
        ) : null}
      </div>
    </div>
  )
}

Widget options

widget-options

tsx
import {
  useGrist,
  useWidgetMetadata,
  type UseGristOptions,
} from "grist-widget-sdk"

import { Button } from "@/components/ui/button"

export const GRIST_OPTIONS: UseGristOptions = {
  requiredAccess: "read table",
  suppressAlerts: ["section-not-linked"],
}

export const WIDGET_METADATA = {
  title: "Widget options",
  description: "Persist UI state with setWidgetOption / widgetOptions.",
} as const

export function WidgetApp() {
  useWidgetMetadata(WIDGET_METADATA)
  const w = useGrist()
  const compact = w.widgetOptions?.compact === true

  return (
    <div className="p-4">
      <Button
        type="button"
        onClick={() => void w.setWidgetOption("compact", !compact)}
      >
        {compact ? "Expand" : "Compact"}
      </Button>
    </div>
  )
}

Slice hooks (performance)

When parts of a big widget need only one slice of state, subscribe narrowly so unrelated state changes do not re-render them:

slice-hooks

tsx
import {
  useGristSelection,
  useGristStatus,
  useGristTheme,
  useGristWrites,
  useWidgetMetadata,
  type UseGristOptions,
} from "grist-widget-sdk"

export const GRIST_OPTIONS: UseGristOptions = {
  requiredAccess: "full",
  columns: [{ name: "Title", type: "Text" }],
}

export const WIDGET_METADATA = {
  title: "Slice hooks",
  description:
    "useGristStatus, useGristSelection, useGristWrites, useGristTheme in separate child components.",
} as const

function Header() {
  const { status } = useGristStatus()
  return <h1 className="text-sm font-medium">status: {status}</h1>
}

function Editor() {
  const { record } = useGristSelection()
  useGristWrites()
  return <p className="text-sm">Editing #{String(record?.id ?? "none")}</p>
}

function ThemeToggle() {
  const { theme } = useGristTheme()
  return <span className="text-xs text-muted-foreground">{theme ?? "no theme"}</span>
}

export function WidgetApp() {
  useWidgetMetadata(WIDGET_METADATA)

  return (
    <div className="flex flex-col gap-2 p-4">
      <Header />
      <Editor />
      <ThemeToggle />
    </div>
  )
}

Schema for LLM context

schema-preview

tsx
import { useGristSchema, useWidgetMetadata, type UseGristOptions } from "grist-widget-sdk"

export const GRIST_OPTIONS: UseGristOptions = {
  requiredAccess: "read table",
  suppressAlerts: ["section-not-linked"],
}

export const WIDGET_METADATA = {
  title: "Schema preview",
  description: "useGristSchema with replicaRowMode schema+samples for LLM context.",
} as const

export function WidgetApp() {
  useWidgetMetadata(WIDGET_METADATA)
  const schema = useGristSchema({ replicaRowMode: "schema+samples" })

  if (!schema.replicaDocument) {
    return <p className="p-4 text-sm text-muted-foreground">Loading schema…</p>
  }

  return (
    <pre className="max-h-svh overflow-auto p-4 text-xs">
      {JSON.stringify(schema.replicaDocument, null, 2)}
    </pre>
  )
}

Released under the ISC License.