Skip to content

State, scopes and triggers

Every Mateu screen works on four bags of values. Components read and write them, ${…} expressions read them, and routes, data sources and actions fill them. They are one of the building blocks, and they behave the same whichever way the screen was authored.

ScopeHoldsLifetimeTypically filled by
statethe page’s field values. A FormField id: name is state.namethe current page, replaced on navigationthe user typing, a route’s state:, a source’s response, an action
dataextra page values that are not fields: totals, lookups, display-only resultsthe current pagean action returning Data, a route’s data:
appStateapp-wide values that survive navigation, such as the active hotel, company or yearthe app sessiona route’s appState:, an action returning AppState, an @AppContext selector
appDataapp-wide data, fetched once and shared by every pagethe app sessiona route’s appData:, an action returning AppData

Each request carries the scopes from the browser to the server, and each response can change them. In a static bundle there is no server, so the scopes never leave the browser. Otherwise the two cases behave the same.

A route can populate all four on entry. They split by nature:

  • state and appState are literals. They are seeded at the defaults level, so anything the client already holds (including the persisted @AppContext) still wins.
  • data and appData are references to a data source. There is no literal data channel, so data always comes from a source. data is fetched when the route loads; appData is fetched once for the whole app.
# the product catalogue: the record page fetches its product, the archived listing
# pins a filter value, and the shell-wide totals are fetched once
- route: products/:id
layout: product.yaml
data: product # GET /api/products/${state.id}, merged into the page
- route: products/archived
layout: products.yaml
state: {status: DISCONTINUED}
appData: catalogue-totals

Path and query parameters land in state too, under the parameter’s name (:id becomes state.id). They follow the same precedence everywhere: fixed > client state > path > query > defaults.

In a view model, the class’s fields are the state: Mateu hydrates them from the incoming state before your method runs, and sends them back afterwards. An action changes the other scopes by returning one of these:

@Button
public Object refresh(HttpRequest rq) {
return List.of(
this, // state: the fields as they are now
new Data(Map.of("total", ProductStore.INSTANCE.count())), // → data.total
new AppData(Map.of("lastRefresh", Instant.now().toString())));
}

HttpRequest.getAppState(...) and HttpRequest.appContext(field) read the app scope on the server. The Java data contexts are covered in Data contexts.

Any label, title, text, URL template or body template can read the scopes:

title: ${state.name}
subtitle: "${i18n.products.title} (${data.total})"
rowRoute: products/${row.id}
source: {url: "/api/products?status=${state.status}"}
In ${…}Where
state, data, appState, appDataeverywhere
i18nany text. Resolved per locale (see Translations)
rowper-row templates such as a listing’s rowRoute
searchText, page, sizea listing source’s url and body
secretonly in a proxied source, resolved on the server. The browser never sees it

The browser evaluates these expressions with Mateu’s own sandboxed evaluator, not with eval, so it is safe under a strict Content Security Policy. Rule and validation expressions can see only state, data, appState, appData and component, never bare field names.

A rule is reactive behaviour that runs in the browser: when a filter expression holds, it sets a value, an attribute (hidden, required, disabled…), a style or a CSS class, or it runs an action (RunAction, RunJS). In YAML, rules appear on a menu’s RuleLink. In Java, a page returns them from a RuleSupplier, and annotations such as @Disabled compile to rules for you. See Rules.

A trigger runs an action without a click. Triggers fire in the browser. Whether the action they name needs a server depends on the action.

TriggerFiresKey fields
OnLoadTriggerwhen the page or component loads, optionally repeatedlyactionId, timeoutMillis, times, condition, background
OnValueChangeTriggerwhen a field changesactionId, propertyName, condition
OnCustomEventTriggerwhen an event is emitted (by an Emit step, UICommand.dispatchEvent or closeModal(event))actionId, eventName, source (DOCUMENT / COMPONENT / SELF), from, condition
OnSuccessTrigger / OnErrorTriggerafter another action succeeds or failsactionId, calledActionId, condition
AutoSaveTriggerwhen the user stops typingactionId, debounceMillis

A small summary page in the catalogue loads its totals on entry, and loads them again whenever any page emits product-saved:

specs/ui/catalogue-summary.yaml
layout:
type: VerticalLayout
content:
- {type: Text, text: "${state.count} products"}
actions:
- id: refresh
restAction:
source: {ref: catalogue-totals}
resultPath: "" # merge the response into the state
triggers:
- type: OnLoadTrigger
actionId: refresh
- type: OnCustomEventTrigger
eventName: product-saved
actionId: refresh

and the page that saves emits that event as the last step of its flow:

actions:
- id: save
validationRequired: true
restAction:
source: {ref: product-update}
successMessage: Saved
- id: saveAndBack
steps:
- type: RunAction
actionId: save
- type: Emit
event: product-saved
- type: Navigate
route: products

In Java the same triggers are annotations (@Trigger(type = TriggerType.OnLoad, actionId = "load"), @SubscribeTo(event = "product-saved", action = "refresh"), @AutoSave) or a TriggersSupplier. C# and Python have [SubscribeTo] / @subscribe_to. See Triggers and component communication.