setState/getState, stateInputs, mountUrlQueryStateBridge, visibility entries.
A single reactive registry behind every "is this open, active, selected, filled in, in the URL" question in the SDK. One store, one evaluator. Visibility, modal open/close, tab selection, drawer expansion, URL query params, connector refetch inputs, computed flags — all read and write the same map.
Source of truth for the registry shape:
stateRegistry.ts. This doc covers what each piece is for and when you'd reach for it.
A button that toggles "active" styling on itself. Three things to wire:
{
"type": "Button",
"props": {
"text": "Overview",
"action": [
{ "type": "set-state", "key": "tabs-1", "value": "overview" }
],
"stateModifiers": [
{
"conditions": [{ "logic": "all", "conditions": [
{ "type": "state", "key": "tabs-1", "operator": "equals", "value": "overview" }
]}],
"modifiers": ["tab-active"]
}
]
}
}
Click writes tabs-1 = "overview". The state condition reads it. When the condition matches, the tab-active modifier composes its classes onto the button. Same pattern handles tabs, accordions, modal triggers, filter chips, anywhere "active" or "open" needs styling.
import {
// Registry (low-level)
setState, getState, getStateValue, deleteState,
// React hooks
useStateValue, useStateEntry, useGlobalStateTick,
// URL bridge — mirrors URLSearchParams to state["url:*"]
mountUrlQueryStateBridge, getUrlState,
} from "@pagehub/sdk";
| Surface | Used by authors via |
|---|---|
| Actions that write state | set-state, toggle-state, clear-state, increment-state, decrement-state (also show-hide writes a visibility entry) |
| Conditions that read state | { type: "state", key, operator, value } in any conditions group |
| className changes when state matches | props.stateModifiers on a node |
| Text / attr interpolation | {{state.<key>}} inside Text, Button text, attrs, image src/alt |
| Form inputs writing to state | FormElement.props.stateBinding: { key, mode?, debounceMs? } |
| Connector refetch on state change | dataSource.stateInputs, dataSource.publishStateKeys on a Data node |
| Derived state (computed) | Container.props.computedStateBindings |
Everything below explains each piece in depth.
Each state entry is a row in stateRegistry.ts:
| Field | Type | Meaning |
|---|---|---|
key | string | Element id (implicit, written by show-hide) or named state you declared. |
kind | "visibility" | "selection" | "flag" | "value" | Discriminator. Drives default toggle pairs and editor hints. |
value | string | null | Current value. Visibility uses "shown"/"hidden", flag "on"/"off", others freeform. |
source | "runtime" | "load" | "editor-preview" | "computed" | Origin of the most recent write. The ESC stack skips computed entries. |
lastWriter | string? | Debug label — "show-hide", "set-state", "esc", "seed", "url-bridge", etc. |
Keys you'll see in practice:
show-hide actions write to the target element's id as a visibility entry."tabs-1", "cart:open", "pdp:current:size". Group-scoped is conventional.url:<param> — populated by the URL bridge. Anything under this prefix mirrors to/from window.location.search.connector:<id>:<field> — published by Data nodes via publishStateKeys (e.g. totalPages, totalCount).cart:count, cart:open — cart visibility + count (managed by the SDK cart).set-state, toggle-state, clear-state, increment-state, decrement-state — declared on any interactive node's action array. All support trigger: "click" | "hover" | "load" and conditions for gating. Full action reference in actions-and-handlers.md.
| Action | Writes |
|---|---|
set-state | One entry. key, optional kind, value. value interpolates {{item.*}}. |
toggle-state | Flips between values?: [a, b]. Defaults: visibility → ["shown","hidden"], flag → ["on","off"]. |
clear-state | Deletes the entry entirely. Useful for reset buttons. |
increment-state | Adds 1 (or step). Supports min, max, wrap. |
decrement-state | Subtracts 1 (or step). Same min/max/wrap controls. |
show-hide | Writes a visibility entry under the target's element id. direction: "tab" swaps panel visibility — pair with a sibling set-state if you need active styling driven by the registry. |
The state condition is available wherever conditions are — node visibility, action gating, stateModifiers, page access.
{ type: "state", key: "tabs-1", operator: "equals", value: "overview" }
Operators: equals, not-equals, exists, not-exists, contains. Unknown keys return null (indeterminate) — same SSR/hydration semantics as url-param. The evaluator reads the registry directly; no extra context wiring.
stateModifiers — class compositionPer-node array on Container/Button/Link. Pairs a conditions group with a list of modifier names. At render, the runtime evaluates each binding's conditions; for true matches, it expands modifier names through the same path useModifiers uses (mod.classes if present, else mod.name). The available list is the union of craft.toolbar.modifiers for the component plus site-saved modifiers under ROOT.props.modifiers[<componentName>].
"stateModifiers": [
{
"conditions": [{ "logic": "all", "conditions": [
{ "type": "state", "key": "tabs-1", "operator": "equals", "value": "panel-1" }
]}],
"modifiers": ["tab-active"]
}
]
Button ships with a built-in tab-active modifier (category State, classes border-primary text-primary) so any block referencing it works out of the box. Override per-site by saving a Button modifier of the same name — site-saved entries win.
Why modifiers and not free-form classes: forces design-system reuse, no class-string typos, and the State popover offers a curated list per node type. Power users can still escape via Tailwind data-attr variants in their normal className field.
{{state.<key>}}Resolves inside Text nodes, Button text / action.href / attrs, Image src / alt. Common pattern — pagination indicator text:
{ "type": "Text", "props": { "text": "Page {{state.url:page}} of {{state.connector:abc:totalPages}}" } }
The URL bar is just another piece of state. Anything in ?foo=bar becomes state["url:foo"] = "bar" and vice versa. Source: urlQueryBridge.ts.
The host viewer mounts it once via useViewerSetup. Authors don't call it directly; they just write to url:<key> and watch the URL track them. Repeated calls are a no-op.
mountUrlQueryStateBridge(); // returns cleanup; idempotent
const q = getUrlState("q"); // convenience reader for `state["url:q"]`
Behavior:
popstate. Each param is written as kind: "value" with lastWriter: "url-bridge".url:* value differs from the current URLSearchParams, it pushStates a new URL. Empty values omit the param.set-state from author code flows in both directions without ringing.Anything that wants to refetch on URL changes (filters, search, pagination) subscribes via dataSource.stateInputs — see below.
stateInputs / publishStateKeysConnector-backed Data nodes subscribe to state keys and refetch when any of them change. Used by storefronts (filter, search, facet, pagination, category nav).
{
"type": "Data",
"props": {
"dataSource": {
"provider": "stripe",
"collection": "products",
"stateInputs": {
"q": "url:q",
"category": "url:category",
"page": "url:page",
"sort": "url:sort"
},
"publishStateKeys": {
"totalPages": "connector:products:totalPages",
"totalCount": "connector:products:totalCount",
"page": "connector:products:page",
"count": "connector:products:count"
}
}
}
}
stateInputs — map of fetch-option name → state key. The connector re-runs whenever any subscribed key flips.publishStateKeys — map of fetch-metadata field → state key. Lets pagination chrome interpolate {{state.connector:products:totalPages}} or gate visibility via state conditions.See data-bindings.md for the full Data / dataSource surface.
stateBindingDrop a stateBinding on a FormElement and changes flow into state automatically. Mirrors the action surface, scoped to inputs.
{
"type": "FormElement",
"props": {
"type": "text",
"stateBinding": { "key": "url:q", "debounceMs": 200 }
}
}
| Field | What it does |
|---|---|
key | State key to mirror to. Anchor tokens supported (url:{{anchor.search}} etc.). |
mode | "checked" for checkbox/radio facets — writes "on"/"" instead of the value. |
debounceMs | Coalesce rapid typing into one write. Common for search inputs. |
defaultValue | SSR seed when the registry hasn't been populated yet. |
computedStateBindingsDeclarative derived values that recompute when their inputs change. Per-Container array. Used to replace ad-hoc inline JS state machines (e.g. PDP variant chip matching). Source: computedState.ts.
{
"type": "Container",
"props": {
"computedStateBindings": [
{
"key": "pdp:current:matching-variant",
"compute": {
"type": "variant-match",
"variantMap": "{{item.variantMapJson}}",
"axes": "{{item.optionNamesCsv}}",
"axisKeyTemplate": "pdp:current:axis:%axis%"
}
}
]
}
}
Built-in compute types:
| Type | What it emits |
|---|---|
variant-match | Matches current axis selections against a JSON variant map; emits the matched variant as JSON. |
variant-axis-availability | For one axis, emits CSV of values whose variant would be unavailable given other selections. |
all-truthy | "on" iff every input key is truthy; else "". |
first-truthy | The value of the first truthy input key; "" when all empty. |
join | Concatenates input values with a separator. |
Computed writes carry source: "computed" so the ESC stack skips them and the inspector can distinguish derived from author-written entries.
useStateValue(key) only rerenders when that exact key changes. A page with 50 stateful regions doesn't fan out across every consumer when one key flips.useGlobalStateTick() for coarse subscribers. Container's render path uses this so its bindings stay live without per-key bookkeeping."shown" pushes onto a LIFO stack. ESC pops the top unless the element opts out via data-close-on-escape="false". Computed entries are skipped, so stuck non-dismissable overlays can't trap visitors.Three layers keep state-driven styling correct on the very first paint:
Container.toHTML stamps data-ph-load-set-state on any node carrying a load-trigger set-state action. A bootstrap script walks those attrs and writes window.__PH_STATE__[key] = { kind, value } synchronously, before any condition script runs. Show-hide load reveals are stamped via the existing data-ph-load-show mechanism and also flip the corresponding visibility entry.useViewerSetup calls seedFromWindow() once on first mount; if the host page emits __PH_STATE__ via inline <script> (or via the load-action script), the registry inherits it before component effects fire. Otherwise the Container mount effect fires fireLoadAction for every load-trigger action — covers set-state, toggle-state, clear-state, show-hide. A one-frame flash window exists between first paint and effect commit; static export closes that gap, React SSR currently accepts it.useShowOnLoadAutoReveal walks the CraftJS graph for load-trigger actions and seeds them with source: "editor-preview". Lets authors preview active styling without leaving the canvas.tabGroup value if you want show-hide direction: "tab" to handle mutual exclusion; otherwise rely on visibility-state conditions.show-hide → target = panel id, direction = tab, group = <groupId>, method = class.set-state → key = <groupId>, kind = selection, value = panel id.<groupId> equals this button's panel id → modifier tab-active.set-state (key = <groupId>, value = first panel id, trigger = load). Seeds the registry on mount so the active button paints correctly on first render. Static export emits this as data-ph-load-set-state; React routes fire it via Container's mount effect.Same flow drives modal triggers, accordion summaries, dropdown chrome — anywhere "active" or "open" needs styling.
StateBindingsInput) + add-picker (StateBindingsAddPicker) + lazy editor panel (StateBindingEditorPanel). Mirrors the Conditions section pattern.state joins url-param, auth, etc. Same UI, same operators.set-state / toggle-state / clear-state / increment-state / decrement-state join the action picker, each with its own form.{{item.*}} interpolation, repeaters, dataSource → data-bindings.mduseViewerSetup lives) → host-configuration.mdregisterClientDataFetcher — runs against state-driven connectors → extensibility.md