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