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:
// 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>| Hook | Re-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.
| Operation | Hook | Measured re-renders |
|---|---|---|
Cursor change (selectRow) | useGrist() | 1 |
| Cursor change | useGristSelection() | 1 |
table.create | useGristWrites() | 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:
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.).
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:
grist.readyhandshake — the SDK does this once. The<GristBoundary>bootingFallbackcovers it.- First subscription emit — Grist sends initial values for
onRecord/onRecordsshortly afterready. The boundary won't render children until the SDK isready, butrecordmay briefly benullbefore the first emit (renders asmode: "empty").
If you find this distracting, add a one-frame delay on the initial render or render a skeleton until w.record != null.