Sanity Library Reference Docs
    Preparing search index...

    Module @sanity/workflow-react - v0.32.0

    @sanity/workflow-react

    Store-agnostic React orchestration for the @sanity/workflow-engine reactive session. You supply a WorkflowObserver (how to watch documents in your store); the hooks do the rest:

    • useWorkflowSession — one instance, live: feeds the engine session ready-gated and returns {evaluation, ready, invalid, error, guards, tick, fireAction, editField, previewField, discardFieldPreview}. A settled commit re-evaluates immediately — no store echo needed.
    • useWorkflowInstances — a live, filterable instance list (definition / stage / document / completed) for list, board, and inbox surfaces. Returns {instances, loading, unreadable, error} — rows the read discipline refuses are skipped from instances and flagged in unreadable, so one bad row can't sink the listing and a silently short list stays impossible. Rows are committed instance docs — feed one to useWorkflowSession for its evaluation and verdicts.
    • useInstancePreviews — every run in the tag partition as the engine's light preview projection, drained page by page until the whole filtered set is held (drained: true means counts are totals) and then kept fresh from the change feed: an event refetches the touched previews alone, and a feed reset re-drains the whole partition. Returns {previews, loading, drained, unreadable, error}.
    • useDocumentWorkflows — which in-flight instances reference this document (GDR URI), matched by the engine's exact watch-set semantics (instanceWatchesDocument). Returns {instances, loading, unreadable, error}.
    • useWorkflowTelemetry — the host's @sanity/telemetry logger, for createEngine({telemetry}). The core also logs one adoption event, Workflows Session Started, through the host's provider when a session is created; it creates no store and resolves no consent — outside a provider everything no-ops.

    Use @sanity/workflow-sdk (App SDK) or @sanity/workflow-studio (Studio) instead of this package unless you're building a new adapter.

    Two entries: the root exports the hooks, their result types, and isInvalidDoc/InvalidDoc; the ./observer entry is the adapter SPI — the observer contract plus the store-building toolkit (the validated-store wrappers validatedInstanceStore / validatedGuardsStore / validatedInstancesStore / validatedPreviewPage, combineDocStores, combineGuardStores, mapSnapshot, the ref-routing helpers, NO_GUARDS, useKeyed). App code never imports from ./observer; adapters build against it.

    editField({field, mode, value}) edits an EditableFieldEvaluation returned by the same session's evaluation.editableFields; callers pass that field object directly and the session derives its engine address. The paired previewField and discardFieldPreview verbs accept the same evaluated field, so inline controls do not need to derive an edit target. Low-level target overloads serve adapter-owned controls that do not start from an evaluated field. Each call is a discrete COMMIT (a history entry, a guard refresh, a cascade that can move the instance, an ifRevisionId write) — not a draft patch. Commits ride deliberate boundaries — blur, Enter, a single-gesture control (switch, picker), an explicit Save — never a timer and never an onChange per keystroke: every keystroke would write history and race the revision check. The per-keystroke feel comes from previewField — staged, never-persisted optimistic previews the next evaluation projects instantly (tolerant at staging; dropped when the target's commit settles) — and discardFieldPreview drops them without committing, reverting the projection when an editing episode ends with nothing to commit.

    interface WorkflowObserver {
    observeInstance(instanceId): DocStore<ObservedInstance>
    observeDocs(documents, perspective): DocStore<ReadonlyMap<string, ObservedDoc>>
    observeGuards({instanceId, resources, sharedIds?}): DocStore<ObservedGuards>
    observeInstances(query): DocStore<ObservedInstances>
    fetchInstancePreviews(query): Promise<readonly unknown[]>
    listenInstanceChanges({query, onEvent}): () => void
    ensureDocumentExists(args): Promise<void>
    }

    type ObserverState<T> =
    | {status: 'loading'}
    | {status: 'ready'; value: T}
    | {status: 'invalid'; invalid: InvalidDoc}
    | {status: 'error'; error: unknown}
    • Every DocStore snapshot is reactive state. Adapters translate their native value and error channels into loading, ready, invalid, or error; a later successful emission clears either failure state. A ready doc value is null when the document does not exist.
    • The engine-owned streams are VALIDATED: ObservedInstance is a parsed WorkflowInstance | null, the guard ready value is a validated array, and the instance-list ready value is ObservedInstances{instances, unreadable}, validated PER ROW so one refused row flags itself instead of failing the list. Wrap your raw stores with the SPI's validatedInstanceStore / validatedGuardsStore / validatedInstancesStore — they apply the engine's read discipline per doc type (model gate + shape parse for instances; parse only for guards, which carry no stamp by design), memoize per snapshot for useSyncExternalStore, and surface validation failures as the invalid state (single doc, guards) or per-row unreadable entries (the list) instead of throwing into render. Content docs stay unvalidated (foreign content).
    • ensureDocumentExists is the seam's one WRITE primitive: make the document exist in the representations the engine's field hydration reads (drafts-over-published, or the release's version doc when releaseId is set), writing nothing when a visible representation already exists. Start flows call it before startInstance so $fields.subject hydrates.
    • The instance list subscribes to the engine-compiled {query, params} (the engine's instancesQuery) against the engine's own dataset, and reports loading until the first read resolves — "still discovering" and "confirmed none" stay distinguishable.
    • The preview pair is the load-all read path: fetchInstancePreviews runs one page of the engine's instancePreviewsQuery and returns the raw rows (the react layer parses per row through validatedPreviewPage), and listenInstanceChanges subscribes the engine's instanceChangesQuery change feed. The adapter owns feed health: one upstream listener per feed however many stores subscribe, reconnect with backoff on error, then a reset event on which consumers resynchronize whatever the gap missed.
    • The list hooks add no debounce, deliberately — coalescing lives where cost lives. Re-running the GROQ is throttled by the underlying store (Studio's listenQuery throttles to ~1/s built-in; the App SDK query store owns its refetch cadence); this layer's per-emission work is a free snapshot swap; and per-row evaluation (a session per list row) rides useWorkflowSession's burst coalescer. Contrast the session path, which coalesces because each emission triggers a real re-evaluation.
    • Guards are a separate stream from the watch-set: the hook surfaces them (session.guards) and feeds them to the session, so action verdicts flip to mutation-guard-denied while a matching guard denies — and back when it is removed. A guard co-locates with the subject it locks, so an instance's guards span the datasets its GDRs name; observeGuards receives that resource set (guardResourcesFor: the engine dataset ∪ each currently-observed subject dataset) and the adapter fans one guard stream per resource, merged by combineGuardStores. Build each per-resource query with the engine's set-shaped instancesGuardQuery. Report loading until the first read resolves and keep resolved snapshots referentially stable between emissions (useSyncExternalStore compares by identity; a resolved-empty read should be the shared NO_GUARDS array).
    • sharedIds is the guard-consolidation seam: the id-set of every co-mounted session, handed identically to each one (the caller passes it as useWorkflowSession's guardScope), so an adapter can subscribe ONE set-shaped query per resource — canonicalize the set (sort + dedupe) so identical scopes produce identical query state — and slice each session's rows back out by sourceInstanceId BEFORE validation, so a sibling's malformed guard can't invalidate this session. The returned stream carries ONLY this instance's guards, and a set that omits instanceId must be refused loudly (its rows could never arrive — the stream would resolve vacuously empty). Omitted, subscribe for the one instance.
    • session.ready includes the guard stream: the first evaluation only runs once the docs and the guards are in hand, so an action never renders allowed and then flips to mutation-guard-denied merely because guards loaded late — and ready is never true while session.invalid is set. invalid (InvalidDoc {reason, documentId, message}) is the explicit validation state: 'model-ahead' means an engine-owned doc requires a newer @sanity/workflow-engine; 'shape-violation' means a stored tree is corrupt. Render the remediation; the state self-clears when a later emission validates. A read failure instead surfaces on session.error; both failure states make ready false and clear after recovery. session.guards is undefined while loading, then the live list — except through a scope-only resubscription (a co-mounted session joined or left guardScope), where the hook holds the last-resolved list so consolidation churn can't blink ready off. Retracted guards disappear from the stream.
    • Verdicts pre-flight the instance's own update write (the write every action/tick commit performs), against guards in the instance's own datasource — a guard only applies inside the datasource it lives in.
    • A commit's full write-set is bigger (spawned child creates, guard docs deployed into foreign datasets, ancestor-instance patches) and is not pre-flighted — the lake is the final arbiter and the engine's rollback/diverged machinery is the backstop; widening the pre-flight is known follow-up work.
    • Pre-flight evaluates with before = after (the projection can't know the post-commit shape): freezes, deny-alls, and metadata/identity predicates surface; predicates that require a delta to allow will read as denied.
    • observeGuards spans the datasets the instance's currently-observed GDRs name (workflow-scope + open-stage subjects + the engine dataset) — where any guard that can deny right now lives. Guards on a subject named only by a closed prior stage in a foreign dataset (already deleted) are not surfaced; the engine's all-stages guardsForInstance is the path that walks every stage.
    • Verdicts still pre-flight only the instance's own update write against same-datasource guards; the full cross-resource write-set forecast is follow-up work.
    • Ancestor instances' guards are not observed.

    See each adapter's README for its store-specific limitations.

    Modules

    index
    observer