State, paths, and write-back
← Architecture · Documentation · Next: Widgets →
Where state lives
State is JSON attached to widget nodes:
- root
stateis 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
| Prefix | Object |
|---|---|
$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
- A known
$prefixis a reference. - Any other string is a literal.
- Prefix-like literal text is escaped with
$$. - An unknown future prefix is unresolved, not literal.
- A missing state path resolves to JSON
null. - Widgets coerce unresolved values to their safe defaults (
Textbecomes empty, booleans use their documented default). - 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 path | Meaning |
|---|---|
$id.offer-42 | The widget node |
$id.offer-42/state | That node's state object |
$id.offer-42/state/liked | state.liked on that node |
$id.slot-reviews/children/- | Append a child |
$root/state/status | Root 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:
| Widget | Value param | Written value |
|---|---|---|
Checkbox | checked | boolean |
Switch | checked | boolean |
Chip | selected | boolean |
TextField | text | string |
Slider | value | number |
RadioGroup | value | selected Radio.value |
{
"type": "TextField",
"params": {
"text": "$root.form.email",
"onChange": {
"type": "analytics",
"event": "email_changed"
}
}
}
The control:
- resolves the current value;
- writes the new value to the same path atomically;
- dispatches optional
onChangeafterward.
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 plusmerge, with$node roots), including$root/theme;
Each commit produces one immutable snapshot. Compose observes snapshots; it never observes in-place JSON mutation.