Grist Widgets
Guide

Cheat sheet

Copy-pasteable widget shapes for daily reference, organized by the pattern each one demonstrates.

One page for daily reference once you understand the mental model. Every TypeScript example below is the full WidgetApp.tsx from examples/<id>/, deployed standalone at /examples/<id>/latest/ and viewable with the emulator inspector at /playground/?url=/examples/<id>/latest/.

The deployed example wraps it in GristWidgetProvider and GristBoundary; snippets show only WidgetApp (and GRIST_OPTIONS).

Provider + hook (selection modes)

hello-world

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-edit — mappedRecord, mapBack, columnMappingStatus, and writes:

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

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-demo — create, update, upsert, destroy (single or array via table):

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

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

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

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

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

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

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

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-board — setCursorPosition on card click:

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

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

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

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>
  )
}

On this page