Skip to content

Cookbook

Ten end-to-end recipes for the most common widget shapes. Each recipe below embeds the canonical source from apps/playground/src/widgets/<id>/WidgetApp.tsx — type-checked when you run pnpm --filter playground build:widgets (root pnpm test includes that step). Open the Live demo link to run the widget in the playground or paste the raw widget URL into Grist.

Edit the selected row

Live demo: Row editor · source

Declares mappings on the provider, reads the current selection via mappedRecord, writes back via table.update + mapBack.

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

Group rows on a Kanban board

Live demo: Task board · source

Render every row from the selected table in columns grouped by a Status field. Iterate w.records, resolve the physical column id via resolveMappedColumnId, project into typed cards.

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

Bulk update many rows

Live demo: Bulk mark done · source

table.update accepts an array. The SDK forwards it to Grist as a single BulkUpdateRecord action — one round-trip, all-or-nothing.

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: "Bulk mark done",
  description: "Bulk table.update with an array of row patches.",
} 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>
  }

  async function markAll() {
    if (!w.records) return
    const updates = w.records.map((row) => ({
      id: row.id as number,
      fields: w.mapBack({ Done: true }),
    }))
    await w.table.update(updates)
  }

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

Read the schema and feed it to an LLM

Live demo: Schema preview · source

useGristSchema() returns a GristReplicaDocument — a single JSON object describing every table, column, and (optionally) sample rows. Drop it into an LLM prompt as context.

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

Run a schema migration

Live demo: Schema migration · source

Schema mutations go through applyActions with the exported builders. Each builder returns a GristUserActionTuple; the array runs as one transaction.

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

Display attachments

Live demo: Attachment gallery · source

extractGristAttachmentIdsFromCell reads a Grist attachment column value (which is an array of file refs). useGrist().fetchAttachmentBlob returns a Blob you can hand to URL.createObjectURL for previews.

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

Drive the cursor in linked sections

Live demo: Task board · source

A widget that displays a list of rows can move the cursor in linked sections of the same Grist page by calling setCursorPosition. Set allowSelectBy: true on the provider so Grist treats this widget as a linking source (see GRIST_OPTIONS in the task-board source).

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

Persist a widget option

Live demo: Widget options · source

widgetOptions survive reloads and travel with the document. Store a view mode, a filter, a layout flag — anything that should not be re-asked on every open.

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

Safe-parse table data with Zod-style results

Live demo: Safe parse · source

safeParseGristTableData validates columnar table data per column and returns both decoded rows and per-cell issues — no exceptions thrown.

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

Test a widget with the emulator

renderWithGrist from grist-widget-sdk/emulator/testing boots the real SDK inline against an in-memory fake host. The hook code paths run unchanged — see Testing and the SDK unit tests under packages/core/tests/sdk/ for full examples (this recipe is a test harness pattern, not a Custom Widget iframe bundle).

Released under the ISC License.