State, paths, and write-back

← Architecture · Documentation · Next: Widgets →

Where state lives

State is JSON attached to widget nodes:

  • root state is this view's document-wide object;
  • any node may own local state;
  • a reference explicitly identifies which object to read or write.
{
  "id": "root",
  "type": "Column",
  "state": { "screenTitle": "Offers" },
  "children": [
    {
      "id": "offer-42",
      "type": "Card",
      "state": { "title": "Flat", "liked": false },
      "children": [
        { "type": "Text", "params": { "text": "$parent.title" } }
      ]
    }
  ]
}

There is no bare $.field and no search-up-until-found behavior.

Reference paths

PrefixObject
$root.Root widget state (theme is "light" or "dark" and selects token variants)
$local.Current widget's own state; never walks up
$parent.Nearest ancestor with state; current widget is skipped
$id.<widgetId>.State on the node with that ID
$theme.Effective theme tokens
$event.Value that triggered this action
$response.HTTP response body within success / error handling
$error.Current action failure within onError / error observers

The suffix is JSONPath-like object traversal:

[
  { "text": "$root.user.name" },
  { "checked": "$parent.liked" },
  { "value": "$id.filters.price.max" },
  { "color": "$theme.colors.primary" }
]

Whole-object forms omit the dot:

[
  { "fromState": "$root" },
  { "fromState": "$local" },
  { "fromState": "$id.offer-42" }
]

Resolution rules

  1. A known $prefix is a reference.
  2. Any other string is a literal.
  3. Prefix-like literal text is escaped with $$.
  4. An unknown future prefix is unresolved, not literal.
  5. A missing state path resolves to JSON null.
  6. Widgets coerce unresolved values to their safe defaults (Text becomes empty, booleans use their documented default).
  7. An unresolved write target is an action failure.
{ "type": "Text", "params": { "text": "$$root.price is documentation text" } }

Local and ancestor state

$local means the node doing the resolution:

{
  "id": "offer",
  "type": "Card",
  "state": { "price": "25M" },
  "children": [
    { "type": "Text", "params": { "text": "$local.price" } },
    { "type": "Text", "params": { "text": "$parent.price" } }
  ]
}

The first Text is empty because that Text has no state. The second reads the card. Use $local for observers/actions declared on the card itself:

{
  "id": "offer",
  "type": "Card",
  "state": { "liked": false },
  "observers": [
    {
      "watch": "$local.liked",
      "action": {
        "type": "analytics",
        "event": "offer_like_changed"
      }
    }
  ]
}

$id.offer.liked names the same state but does not depend on tree position.

Patch paths

Dotted prefixes ($root.status, $id.offer-42.liked) always mean state. patch path / from use a different grammar: the $ root is a widget node, and state is an explicit JSON Pointer.

Patch pathMeaning
$id.offer-42The widget node
$id.offer-42/stateThat node's state object
$id.offer-42/state/likedstate.liked on that node
$id.slot-reviews/children/-Append a child
$root/state/statusRoot state.status
/children/-RFC 6901 from the action targetId

$id.<id> runs until the first /. $local and $parent in a patch path are the current widget and its nearest ancestor widget; they are invalid in SSE/HTTP patches. Bindings, observers, and fromState keep the dotted state paths on this page.

Param binding

Params are literals or paths—there is no bind wrapper. Envelope onClick and modifier are not params; values inside onClick still resolve as action fields:

{
  "type": "Button",
  "onClick": {
    "type": "http",
    "method": "POST",
    "url": "$root.submitUrl",
    "body": { "fromState": "$root.form" },
    "success": { "apply": "none" }
  },
  "params": {
    "enabled": "$root.canSubmit"
  },
  "children": [
    { "type": "Text", "params": { "text": "$root.submitLabel" } }
  ]
}

Resolution is recursive for maps and arrays in action headers, query, args, analytics params, and similar JSON fields.

Interactive write-back

These widget params are two-way when their value is a writable state path:

WidgetValue paramWritten value
Checkboxcheckedboolean
Switchcheckedboolean
Chipselectedboolean
TextFieldtextstring
Slidervaluenumber
RadioGroupvalueselected Radio.value
{
  "type": "TextField",
  "params": {
    "text": "$root.form.email",
    "onChange": {
      "type": "analytics",
      "event": "email_changed"
    }
  }
}

The control:

  1. resolves the current value;
  2. writes the new value to the same path atomically;
  3. dispatches optional onChange afterward.

Therefore onChange sees the new value:

{
  "type": "Switch",
  "params": {
    "checked": "$id.settings.notifications",
    "onChange": {
      "type": "http",
      "method": "PUT",
      "url": "/v1/settings",
      "body": { "fromState": "$id.settings" },
      "success": { "apply": "none" }
    }
  }
}

A literal value is read-only:

{ "type": "Switch", "params": { "checked": true } }

The UI may still emit onChange, but no automatic state write occurs. Prefer a path for interactive controls so recomposition is deterministic.

Explicit writes

Use patch for UI-only writes:

[
  { "type": "patch", "patch": [{ "op": "add", "path": "$root/state/status", "value": "loading" }] },
  { "type": "patch", "patch": [{ "op": "add", "path": "$local/state/liked", "value": true }] },
  { "type": "patch", "patch": [{ "op": "add", "path": "$id.offer-42/state/likes", "value": 11 }] }
]

Creating a missing key under an existing object succeeds. Writing through a missing scope ($id.unknown, $local without local state) fails and enters action error handling.

Store commits

BduiStore can atomically:

  • apply a patch (RFC 6902 ops plus merge, with $ node roots), including $root/theme;

Each commit produces one immutable snapshot. Compose observes snapshots; it never observes in-place JSON mutation.

← Architecture · Documentation · Next: Widgets →

AppSana — экраны под задачу в приложениях, которые у вас уже есть.