State system & URL bridge

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.


The smallest stateful thing that works

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.


The whole surface at a glance

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";
SurfaceUsed by authors via
Actions that write stateset-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 matchesprops.stateModifiers on a node
Text / attr interpolation{{state.<key>}} inside Text, Button text, attrs, image src/alt
Form inputs writing to stateFormElement.props.stateBinding: { key, mode?, debounceMs? }
Connector refetch on state changedataSource.stateInputs, dataSource.publishStateKeys on a Data node
Derived state (computed)Container.props.computedStateBindings

Everything below explains each piece in depth.


Entries

Each state entry is a row in stateRegistry.ts:

FieldTypeMeaning
keystringElement id (implicit, written by show-hide) or named state you declared.
kind"visibility" | "selection" | "flag" | "value"Discriminator. Drives default toggle pairs and editor hints.
valuestring | nullCurrent 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.
lastWriterstring?Debug label — "show-hide", "set-state", "esc", "seed", "url-bridge", etc.

Keys you'll see in practice:

  • Element idshow-hide actions write to the target element's id as a visibility entry.
  • Named state you pick"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).

Writing state — actions

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.

ActionWrites
set-stateOne entry. key, optional kind, value. value interpolates {{item.*}}.
toggle-stateFlips between values?: [a, b]. Defaults: visibility → ["shown","hidden"], flag → ["on","off"].
clear-stateDeletes the entry entirely. Useful for reset buttons.
increment-stateAdds 1 (or step). Supports min, max, wrap.
decrement-stateSubtracts 1 (or step). Same min/max/wrap controls.
show-hideWrites 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.

Reading state — three ways

1. Conditions

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.

2. stateModifiers — class composition

Per-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.

3. Interpolation — {{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}}" } }

URL ↔ state bridge

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:

  • URL → state on mount and on every popstate. Each param is written as kind: "value" with lastWriter: "url-bridge".
  • State → URL on every global tick. If any url:* value differs from the current URLSearchParams, it pushStates a new URL. Empty values omit the param.
  • The loop is broken by value comparison, so writing 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.


Connector refetch — stateInputs / publishStateKeys

Connector-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.


Form inputs that write to state — stateBinding

Drop 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 }
  }
}
FieldWhat it does
keyState key to mirror to. Anchor tokens supported (url:{{anchor.search}} etc.).
mode"checked" for checkbox/radio facets — writes "on"/"" instead of the value.
debounceMsCoalesce rapid typing into one write. Common for search inputs.
defaultValueSSR seed when the registry hasn't been populated yet.

Computed state — computedStateBindings

Declarative 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:

TypeWhat it emits
variant-matchMatches current axis selections against a JSON variant map; emits the matched variant as JSON.
variant-axis-availabilityFor 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-truthyThe value of the first truthy input key; "" when all empty.
joinConcatenates 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.


Reactivity

  • Keyed subscriptionsuseStateValue(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.
  • Global tickuseGlobalStateTick() for coarse subscribers. Container's render path uses this so its bindings stay live without per-key bookkeeping.
  • ESC stack — every visibility entry set to "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.

First paint — pre-hydration seeding

Three layers keep state-driven styling correct on the very first paint:

  • Static exportContainer.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.
  • React SSRuseViewerSetup 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.
  • EditoruseShowOnLoadAutoReveal walks the CraftJS graph for load-trigger actions and seeds them with source: "editor-preview". Lets authors preview active styling without leaving the canvas.

Author flow — building a tab group from blank Containers

  1. Drop a tab bar Container, drop panel Containers. Give panels a tabGroup value if you want show-hide direction: "tab" to handle mutual exclusion; otherwise rely on visibility-state conditions.
  2. Drop a tab button. In the Action panel chain two actions:
    • show-hide → target = panel id, direction = tab, group = <groupId>, method = class.
    • set-state → key = <groupId>, kind = selection, value = panel id.
  3. In the State popover, add a binding: when state <groupId> equals this button's panel id → modifier tab-active.
  4. On the FIRST panel only, add a load-trigger 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.


Editor surfaces

  • State section under the Interactions tab on every Container/Button. Chip-row body (StateBindingsInput) + add-picker (StateBindingsAddPicker) + lazy editor panel (StateBindingEditorPanel). Mirrors the Conditions section pattern.
  • Conditions popoverstate joins url-param, auth, etc. Same UI, same operators.
  • Action popoverset-state / toggle-state / clear-state / increment-state / decrement-state join the action picker, each with its own form.

Where to go from here