Skip to content

Performance

The SDK is designed so a small widget can use useGrist() everywhere without thinking, and a large widget can opt into slice hooks to keep render cost flat.

Default behavior

A useGrist() call inside a component subscribes to the full widget object. When any field changes — selection, action status, theme — every subscriber re-renders.

For most widgets this is fine. Hooks are cheap, virtual DOM diffing is fast, and writes are infrequent. Profile before reaching for the slice hooks.

Slice hooks

When a deep tree includes both a read view and an action button:

tsx
// 1) Provider once, at the root.
<GristWidgetProvider options={...}>
  <Page />          // useGristSelection() — re-renders on selection
  <Actions />       // useGristWrites()    — does NOT re-render on selection
  <StatusBar />     // useGristStatus()    — re-renders only on status / error / table id
</GristWidgetProvider>
HookRe-renders on
useGrist()Anything in the combined object.
useGristSelection<TRow, TMapped>()record, records, mappings, mode, widgetOptions.
useGristWrites()actionStatus, actionError.
useGristStatus()status, isAvailable, error, reload, currentTableId.
useGristTheme()theme.

Each slice hook returns an object whose identity is stable while the underlying slice doesn't change. Pass individual callables (mapBack, table, reload, …) into React.memo children — they keep the same reference across unrelated updates (covered by slice-identity.test.tsx).

Render budget (measured)

Baseline from pnpm --filter grist-widget-sdk bench on presets.todoList() in jsdom + Vitest (packages/core/bench/results.json). Counts are probe re-renders per operation (delta on a single subscriber component), not wall-clock time.

OperationHookMeasured re-renders
Cursor change (selectRow)useGrist()1
Cursor changeuseGristSelection()1
table.createuseGristWrites()0
reload()useGristSchema()1

Slice isolation: a React.memo child that only receives table / getTable / applyActions does not re-render when selection changes; a child that only receives records does not re-render when the cursor moves within the same 1000-row list (the onRecords payload is not re-emitted on cursor-only changes).

Re-run the bench after intentional render-behavior changes and diff results.json in review.

Memoize derived data

When you compute something from w.records, wrap it in useMemo so child memoized components stop re-rendering on unrelated state churn:

tsx
const sorted = useMemo(
  () => (w.records ?? []).slice().sort(byPriority),
  [w.records],
)

w.records reference is stable within a single subscription batch — useMemo([w.records]) is sufficient.

Avoid stacking useGristReady

Calling useGristReady in many leaf components is safe (the SDK coalesces grist.ready and subscriptions), but every instance is its own React effect. Prefer one provider call + one hook per leaf.

Lazy reads

fetchTable, fetchTableRows, and fetchRow are not cached by the SDK. If you call them inside a render path, wrap in useEffect or a query layer (React Query, SWR, etc.).

tsx
function Reports() {
  const w = useGrist()
  const { data: rows } = useQuery({
    queryKey: ["table", "Reports"],
    queryFn: () => w.fetchTableRows("Reports", { order: "grist" }),
    enabled: w.isReady,
  })
  return <Grid rows={rows} />
}

Avoid serialising large widgetOptions

widgetOptions is broadcast to every subscriber on every change. Keep it small: store IDs, not full data.

Streaming attachments

fetchAttachmentBlob is preferred over fetchAttachmentBase64 for files > a few KB. The Blob path skips the base64 round-trip.

Cold-start time

Two factors dominate:

  1. grist.ready handshake — the SDK does this once. The <GristBoundary> bootingFallback covers it.
  2. First subscription emit — Grist sends initial values for onRecord / onRecords shortly after ready. The boundary won't render children until the SDK is ready, but record may briefly be null before the first emit (renders as mode: "empty").

If you find this distracting, add a one-frame delay on the initial render or render a skeleton until w.record != null.

Released under the ISC License.