Widget options
Every Grist Custom Widget can persist a JSON object alongside the section configuration. This is how you store user preferences — view modes, filters, theme tweaks, custom column rules, AI prompts, etc.
Quick read from useGrist()
const w = useGrist()
w.widgetOptions // current value (Record<string, unknown> | null)
w.widgetInteraction // { access_level?: string, ... } from Grist
await w.getWidgetOptions() // explicit fetch
await w.getWidgetOption("theme")
await w.setWidgetOption("theme", "dark")
await w.setWidgetOptions({ theme: "dark", autoRefresh: true })
await w.patchWidgetOptions({ theme: "dark" }) // merge with current
await w.clearWidgetOptions()widgetOptions updates reactively whenever Grist emits onOptions. You don't need to re-fetch after writing.
Opening the configuration panel
Set hasCustomOptions: true on the provider to show the gear icon in Grist's widget chrome. The SDK forwards onEditOptions so you can navigate to a settings route:
<GristWidgetProvider
options={{
hasCustomOptions: true,
onEditOptions: () => navigate("/settings"),
}}
>Typed options with debounce
When using <GristWidgetProvider>, useGristWidgetOptionsFromContext gives you:
- Typed defaults merged with whatever Grist has stored.
- Debounced writes (default 250ms).
- A namespace mode so you can share the options bag with unrelated state.
import { useGristWidgetOptionsFromContext } from "grist-widget-sdk"
type Settings = { theme: "light" | "dark"; rowsPerPage: number }
function SettingsPanel() {
const { options, patchOptions, reset, loading } = useGristWidgetOptionsFromContext<Settings>({
defaults: { theme: "light", rowsPerPage: 25 },
debounceMs: 200,
namespace: "ui", // optional — writes to grist.setOption("ui", value)
})
if (loading) return <Spinner />
return (
<>
<select
value={options.theme}
onChange={(e) => patchOptions({ theme: e.target.value as Settings["theme"] })}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<button onClick={reset}>Reset</button>
</>
)
}Standalone alternative
If your widget doesn't use <GristWidgetProvider>, the advanced hook useGristWidgetOptions from grist-widget-sdk/advanced provides the same API but manages its own grist.ready() call internally. Don't mix the two — pick one per widget.
Storage model
Behind the scenes, Grist stores a single JSON object per widget. The SDK exposes two write modes:
| API | Effect |
|---|---|
setWidgetOption(key, value) | grist.setOption(key, value) — replaces one key. |
setWidgetOptions(next) | grist.setOptions(next) — replaces the whole object. |
patchWidgetOptions(patch) | Merges patch into the current options and writes the merged result back. |
When in doubt, prefer patchWidgetOptions — it's the closest analogue to React's "spread + override" pattern.
Interaction metadata
widgetInteraction is the second argument Grist sends to onOptions. It carries metadata about the widget's environment, notably access_level. You can use it to gate dangerous UI:
const canWrite = w.widgetInteraction?.access_level === "full"Pitfalls
- Don't mix
w.setWidgetOptions(...)withuseGristWidgetOptionsFromContextin the same widget. Pick one writer or namespace the latter. - Don't write on every keystroke. Either debounce manually or use the advanced hook.
- Don't store large data here. Widget options are sent with every
onOptionsevent. Keep payloads under a few KB.