Skip to content

State Management

State is declared as the state object in a module component. Values MUST be resolved JSON literals — expressions and references are not permitted as initial state values.

"state": {
"email": "",
"password": "",
"isLoading": false,
"results": [],
"config": {},
"onClose": false
}

Initial state values MUST lie inside the domain of any component bound to them. Every input component declares a fixed value domain — a slider’s [0, 1], a picker’s _options list, a toggle’s bool, a datepicker’s Unix-seconds number, a textfield’s string. The state key bound via _value / _selection / _isOn / _date / _text MUST declare an initial that satisfies the bound component’s domain. The runtime does NOT auto-clamp, coerce, or substitute defaults for out-of-domain initials — they produce broken UI (“selection invalid” picker warnings, ignored date pickers, etc.).

When the natural default is not expressible as a literal (e.g. “today” for a date picker, or the first element of a dynamically-loaded option list), declare a placeholder literal (0, "", etc.) and resolve to the proper value inside an onAppear state action.

The runtime performs no schema translation at data-flow boundaries. Whenever a value crosses a boundary in the system — a state action’s input written and later read; a repo.create / repo.update body written and later read via @{item.<key>} in a dynamic prototype; a host runtime.trigger payload received by an onCustom handler; an onChange.observed key’s new value used in the action chain — the runtime passes the value through unchanged. There is no key renaming, no type coercion, no field synthesis. The schema you write IS the schema readers see. Drift is silent: missing or type-incompatible paths resolve to .none (see §8.8) and the corresponding UI fields appear empty. Authors are responsible for maintaining schema consistency across every boundary.

Authors own end-to-end value semantics. The runtime applies the constraints declared on a component — a picker’s _options, a textfield’s _keyboard, a text’s _format, a datepicker’s _components — but it does NOT infer intent. Each input component has a fixed value contract (e.g. a slider produces a double in 0..1; a toggle produces a bool; a textfield produces a string; see each input’s section in §4.4). There is no implicit alignment between that contract value and the value stored in state, the value displayed back to the user, or the value written into a repository. If your user-facing intent (“integer severity 1-10”) doesn’t match an input’s raw contract (slider gives 0..1), the author MUST scale and cast at every consumer — display label, repository write, history-row read. If a displayed value should be formatted ("5/10", "3 minutes ago", "$12.99"), the author wraps it with _format or composes it via a {{ … }} expression. The raw value flows unchanged through state, into any repository, and back through @{item.<key>} reads — so its declared precision and format must be the precision and format the author intends throughout. The runtime never rounds, casts, clamps, or relabels on the author’s behalf.

"_text": "${email}"
"_text": "{{ ${weather.current.temperature_2m} + '\u00b0C' }}"
"_condition": "${isLoading}"

State reads are scoped strictly to the module that declares the key. ${key} resolves only within the current module’s state; if the key is not declared in the current module, the reference resolves to .none (null). A module cannot read state declared by its parent, child, or sibling modules.

Context (@{key}) is the opposite: author-declared context keys propagate down across every nested component, including across module boundaries. A parent module’s custom context entries are merged into each child module’s context at init time, so @{key} inside the child resolves against the chain self → parent → … → root (nearest binding wins). Internal _-prefixed keys (_text, _source, etc.) are component-local and do NOT cross — each component declares its own. This is the canonical mechanism for threading shared data (a dataset, an active filter, a selected id) from a root module into nested views without duplicating it as state.

The state action is the only mechanism for updating state. It applies a partial merge: the runtime compares the input dictionary keys against the module’s declared state keys and updates only those that match.

Key rules for v1.0:

  • State can only be updated with fully resolved values — no unresolved references or expression objects.
  • You cannot add new state keys at runtime — only keys declared in the initial state block can be updated.
  • State is owned by the module that declares it. Parent modules cannot write to child-module state. Sibling modules cannot write to each other’s state.
  • State changes trigger re-rendering of the module’s subtree.
  • When a component inside a module dispatches a state action, the action updates the state of the enclosing module for any keys that module has declared (partial merge; non-declared keys are ignored at the local level — but see name-matched propagation below).
  • Name-matched upward propagation (chain-less writes only). After the local update, when the dispatching state action has no output.success chain, the runtime propagates the update upward through the component hierarchy. For each ancestor module that has declared a state key with the same name as one being updated, that ancestor’s state key is updated by partial merge with the same value. Propagation traverses the full ancestor chain — any ancestor at any depth with a matching declaration receives the update. The dispatching module does NOT need to declare the key for propagation to reach an ancestor that does. There is no downward or sibling propagation.
  • Chains suppress propagation. When a state action has output.success, the local merge still runs, but the write does not propagate upward. The chain runs in the dispatching module’s context, preserving ${...} / @{item} resolution. Authors who need propagation and a sequenced async follow-up must dispatch two separate state actions (one chain-less write that propagates, plus one chained write for the async sequencing) — see §10.2 for the canonical pattern.
  • State action chains may contain only non-state actions (see §10.2). State-to-state chaining is forbidden — combine into a single multi-key input dictionary.