Observers
← Themes · Documentation · Next: Streaming →
Observers dispatch actions in response to state changes or action failures. They are declared in a widget's observers array.
State observer
{
"id": "root",
"type": "Column",
"state": {
"form": { "agreed": false }
},
"observers": [
{
"id": "acceptLegal",
"watch": "$root.form.agreed",
"trigger": "becomesTrue",
"skipInitial": true,
"debounceMs": 0,
"once": false,
"action": {
"type": "http",
"method": "POST",
"url": "/v1/legal/accept",
"success": { "apply": "none" }
}
}
],
"children": []
}
| Field | Type | Default | Meaning |
|---|---|---|---|
id | string | absent | Stable identity for once, diagnostics, and replacement |
watch | path | required | State path or $error |
trigger | string | changed | State comparison mode |
equals | JSON | absent | Comparison value for equals |
jsPredicate | string | absent | Sandboxed predicate for predicate |
skipInitial | boolean | true | Suppress first state snapshot |
debounceMs | integer | 0 | Coalesce rapid changes |
once | boolean | false | Disable after first successful action dispatch |
action | action | required | Action to dispatch |
Triggers
changed
Structural JSON inequality:
{
"watch": "$root.filters",
"trigger": "changed",
"debounceMs": 250,
"action": {
"type": "http",
"method": "POST",
"url": "/v1/search",
"body": { "fromState": "$root.filters" },
"success": {
"apply": "patch",
"from": "$response.ops"
}
}
}
equals
Fires when the new value structurally equals equals and the old value did not:
{
"watch": "$root.upload.status",
"trigger": "equals",
"equals": "complete",
"action": { "type": "toast", "message": "Upload complete" }
}
becomesTrue / becomesFalse
Boolean edge trigger:
{
"watch": "$local.expired",
"trigger": "becomesTrue",
"once": true,
"action": {
"type": "dialog",
"presentation": "alert",
"content": {
"type": "Column",
"children": [
{ "type": "Text", "params": { "text": "Session expired", "variant": "titleMedium" } },
{
"type": "Button",
"onClick": { "type": "dismiss" },
"children": [{ "type": "Text", "params": { "text": "OK" } }]
}
]
}
}
}
Only literal JSON booleans count; strings such as "true" do not.
predicate
Runs a safe synchronous predicate:
{
"watch": "$root.form",
"trigger": "predicate",
"jsPredicate": "function predicate(currentJson, previousJson) { var c = JSON.parse(currentJson); return JSON.stringify(!!(c.email && c.agreed)); }",
"action": {
"type": "patch",
"patch": [{ "op": "add", "path": "$root/state/canSubmit", "value": true }]
}
}
It must declare predicate(currentJson, previousJson) and return a JSON boolean string. It uses the same isolate, policy, time, source, and payload limits as jsUpdateState. A disabled/failed predicate evaluates to false and is logged; it does not enter the action error pipeline because no action was dispatched.
Scope
An observer is evaluated in the node where it is declared:
$localis that node's own state;$parentis its nearest state-owning ancestor;$rootand$id.*are absolute;- the observer's action inherits the declaring node as
sourceWidgetId; - observer actions may use
patch;$local/$parentin patch paths are the declaring widget and its nearest ancestor widget.
{
"id": "offer-42",
"type": "Card",
"state": { "liked": false },
"observers": [
{
"watch": "$local.liked",
"trigger": "changed",
"action": {
"type": "analytics",
"event": "offer_like_changed",
"params": {
"offerId": "$local.id",
"liked": "$local.liked"
}
}
}
],
"children": []
}
Scheduling and loop safety
State observers run after a dispatch commit/group, not during Compose recomposition.
- Commit store snapshot.
- Compare each watched value with its previous value.
- Apply trigger and debounce.
- Queue matching observer actions.
Rules:
- structural equality prevents same-value loops;
- observer chains are capped at depth 3 per original dispatch;
- excess events are dropped and logged;
- a
syncgroup produces one state-observer evaluation at completion/failure; - each completed
asyncchild may produce its own evaluation; onChangeautomatic write-back occurs before observer evaluation;- restore/hydration is an initial snapshot, so
skipInitial: trueprevents process-restore side effects.
Error observers
watch: "$error" is the general action-error handler. It does not observe state and ignores state-trigger fields.
{
"id": "root",
"type": "Column",
"observers": [
{
"id": "handleAllErrors",
"watch": "$error",
"action": {
"type": "async",
"actions": [
{
"type": "toast",
"message": "$error.message",
"style": "error"
},
{
"type": "analytics",
"event": "bdui_action_failed",
"params": {
"code": "$error.code",
"actionType": "$error.actionType",
"source": "$error.source"
}
}
]
}
}
],
"children": []
}
Scope:
- root error observer sees every failure in this view;
- non-root observer sees failures dispatched by that node or descendants;
- failures without a
sourceWidgetId(for example SSE) reach root only.
Order:
- HTTP error-body apply, if any;
- failed action's optional
onError; - matching error observers from nearest node outward to root;
- default UI toast only if no
onErroror error observer ran.
$error is ephemeral context, never written to state. A failing error handler/observer is logged and cannot recursively trigger error observers.
Use the root error observer for consistent product-wide reporting in that view. Use per-action onError for rollback, cleanup, or a message that requires action-specific context.