Watchers
React to state changes by triggering actions when watched paths update.
The watch Field#
Elements can have an optional watch field that maps state paths to action bindings. When the value at a watched path changes, the bound actions fire automatically.
watch is a top-level field on the element (sibling of type, props, children) — not inside props.
{
"type": "Select",
"props": {
"label": "Country",
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": {
"action": "loadCities",
"params": { "country": { "$state": "/form/country" } }
}
},
"children": []
}When the user selects a different country, the loadCities action fires with the new country value. The action handler can fetch city data and update state, causing a dependent city Select to re-render with new options.
Cascading Selects#
A common pattern is cascading dropdowns where selecting a value in one field loads options for another:
{
"root": "form",
"elements": {
"form": {
"type": "Stack",
"props": { "direction": "vertical", "gap": "md" },
"children": ["country-select", "city-select"]
},
"country-select": {
"type": "Select",
"props": {
"label": "Country",
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": [
{ "action": "loadCities", "params": { "country": { "$state": "/form/country" } } },
{ "action": "setState", "params": { "statePath": "/form/city", "value": "" } }
]
},
"children": []
},
"city-select": {
"type": "Select",
"props": {
"label": "City",
"value": { "$bindState": "/form/city" },
"options": { "$state": "/availableCities" },
"placeholder": "Select a city"
},
"children": []
}
},
"state": {
"form": { "country": "", "city": "" },
"availableCities": []
}
}The watcher on country-select fires two actions when the country changes:
loadCities— fetches and writes city options to/availableCitiessetState— resets the city selection
The city Select reads its options from { "$state": "/availableCities" }, so it automatically updates when the data is loaded.
Action Handler#
const handlers = {
loadCities: async (params) => {
const cities = await fetchCities(params.country);
// setState is called by the runtime to write the result
return cities;
},
};Or with defineRegistry:
const { registry, handlers } = defineRegistry(catalog, {
components: { /* ... */ },
actions: {
loadCities: async (params, setState) => {
const response = await fetch(`/api/cities?country=${params.country}`);
const cities = await response.json();
setState('/availableCities', cities);
},
},
});Multiple Watchers#
An element can watch multiple state paths. Each path maps to one or more action bindings:
{
"watch": {
"/form/startDate": { "action": "validateDateRange" },
"/form/endDate": { "action": "validateDateRange" },
"/form/quantity": [
{ "action": "recalculateTotal" },
{ "action": "checkInventory", "params": { "qty": { "$state": "/form/quantity" } } }
]
}
}Behavior#
- Watchers only fire on value changes, not on the initial render
- Comparison is by reference (
===), not deep equality - Action params support the same expressions as event bindings (
$state,$item,$index) - Multiple action bindings on the same path execute sequentially
When to Use watch vs on#
| Mechanism | Trigger | Use Case |
|---|---|---|
on | User interaction (press, change, blur) | Button clicks, input changes, form submissions |
watch | State value change (any source) | Cascading data, derived state, cross-field sync |
Use on when reacting to direct user actions. Use watch when a state change (from any source — user input, action handler, or external store update) should trigger side effects.
Next#
- Data Binding — connect elements to state
- Computed Values — derive prop values
- Visibility — conditionally show or hide elements