State
State specification
What State Is
Section titled “What State Is”State is Proto UI’s host-neutral state expression. It defines state, stores state, and records how state changes over time.
State is not an information channel. It does not carry App Maker configuration into a component, does not feed component results back to the user, and does not share environment between components. It represents the Component’s own internal continuity over time.
State is also not a side-effect mechanism. Mutating state does not implicitly trigger render, commit, feedback flush, or any host side effect. The state change is recorded; reflecting that change in UI output requires explicit update, rule, feedback, or another path defined by a separate contract.
Definition and State Machine
Section titled “Definition and State Machine”The prototype-author entrypoint is def.state.*. Its core action is defining a state, and definition is setup-only.
const open = def.state.bool('open', false);const placement = def.state.enum('placement', 'bottom', { options: ['top', 'right', 'bottom', 'left'] as const,});def.state.* does not write runtime data into the component. It defines a state slot and returns a controlled view of that slot. Behind that view is a state machine with a default value, current value, change records, watchers, and lifecycle boundaries.
After definition, the state view survives across setup and runtime. Different view APIs have different phase rules:
| API | Phase | Semantics |
|---|---|---|
get() | setup + runtime | Reads the current value; during setup, reads the current default value |
setDefault(value) | setup-only | Sets the default value |
set(value, reason?) | runtime callback-only | Changes the current value during runtime |
watch(callback) | setup-only | Registers observation for borrowed/observed state |
get() is the special case: it is available during both setup and runtime. During setup, no runtime mutation exists yet, so it reads the current default value; during runtime, it reads the current state-machine value.
State Views
Section titled “State Views”Prototype authors do not receive raw state slots or raw data sources. App Makers do not receive those raw sources either, even when state is exposed. Every access goes through a view.
Prototype-author-side views come in three kinds:
| View | Used when | Capabilities |
|---|---|---|
owned | The current prototype creates and fully controls the state | get, setDefault, set |
borrowed | The current prototype can control the state but does not fully own it | get, setDefault, set, watch |
observed | The current prototype cannot control the state at all | get, watch |
Owned View
Section titled “Owned View”An owned view means the state was directly created by the current prototype and is fully controlled by it.
const open = def.state.bool('open', false);
def.event.on('press.commit', () => { open.set(!open.get());});Owned views intentionally do not expose watch(). This is a deliberate constraint: prototype authors should not model changes they fully control as listener side effects. If a state change should affect output, use explicit update, rule, or a clearer coordination mechanism.
Borrowed View
Section titled “Borrowed View”A borrowed view means the current prototype can control the state, but is not its full and direct owner. Common examples are asHook state and official interaction state.
const pressed = def.state.fromInteraction('pressed');
pressed.watch((run, event) => { if (event.type === 'next') { run.update(); }});Borrowed views can both set() and watch(). They fit states that are controllable but not fully owned: the current prototype can affect them, but should not treat them as entirely private state.
Observed View
Section titled “Observed View”An observed view means the current prototype cannot control the state at all; it can only read and observe it. Observed views do not provide setDefault() or set().
Observed views fit state whose source of truth is elsewhere. The current prototype may react to it, but cannot rewrite it.
Value Spec
Section titled “Value Spec”State definition admits only a finite set of host-neutral state kinds:
| API | Kind | Requirement |
|---|---|---|
def.state.bool | bool | boolean value |
def.state.enum | enum | must declare options |
def.state.string | string | may describe the domain with metadata such as options |
def.state.numberRange | number.range | must declare min and max |
def.state.numberDiscrete | number.discrete | may describe the domain with metadata such as min, max, and step |
State values still stay within JSON-compatible value boundaries. State must not require functions, symbols, class instances, DOM objects, or other host objects to express its semantics.
Metadata must also remain host-neutral. Enum options, number ranges, and steps are portable value-domain descriptions; they should not bind state semantics to a particular host control or host object.
Value-domain validation is still a governance gap. The contracts define the value domains, but full validation for every definition, setDefault, and set call is not yet treated as an implemented guarantee.
Watch and StateEvent
Section titled “Watch and StateEvent”watch() is a setup-only registration API that exists only on borrowed and observed views. It observes state changes; it is not a reactive rendering system.
Watch callbacks receive StateEvent records:
state.watch((run, event) => { if (event.type === 'next') { // event.prev // event.next // event.reason }});When a state value really changes from prev to next, watchers receive:
{ type: 'next', prev, next, reason? }If Object.is(prev, next) is true, the write is not an actual state change. setDefault() also does not dispatch next, because it only changes the default-value plan; it is not a runtime value transition.
When lifecycle disconnects a watcher from a state slot during unmount or dispose, the watcher may receive:
{ type: 'disconnect', reason: 'unmount' }Watch callbacks must not implicitly trigger render, commit, or update. They only observe and record state changes; updating the UI still requires an explicit path.
Interaction State
Section titled “Interaction State”Interaction state is a State subdomain. It can also be understood as State + Event composed into one module.
const hovered = def.state.fromInteraction('hovered');const disabled = def.state.fromInteraction('disabled');Declaring interaction state implicitly introduces runtime-managed event wiring. Prototype authors do not need to hand-write pointer.enter, pointer.leave, host:focus, or similar event subscriptions to maintain these official interaction states; runtime packages, modules, adapters, and host capabilities coordinate that work.
The same interaction state for the same interaction subject is always the same state reference. If an asHook internally calls fromInteraction('pressed'), the prototype author using that asHook can later call fromInteraction('pressed') directly and receive the same state, without requiring the asHook to return it.
Interaction state currently uses borrowed views. In the long term, some interaction states may be better modeled as observed views; but until adapters and modules can absolutely guarantee the truth of every interaction state, allowing prototype authors to correct these states remains an important escape capability.
Lifecycle
Section titled “Lifecycle”State views are bound to the component instance lifecycle.
While the instance is alive, view APIs are available according to their phase rules. During an unmounted callback, the instance is still considered alive; state operations valid in runtime phase are still allowed.
After dispose, all state view APIs and watch registrations must become invalid. Internal subscriptions must also be cleaned up so no leftover watcher callback fires after dispose.
Contract Preview
Section titled “Contract Preview”Relationship To Tests
Section titled “Relationship To Tests”State test mappings are split between the core State domain and the interaction subdomain:
| Test Entity | Main Coverage |
|---|---|
T-STATE-0001 | definition, owned view phase guards, no-implicit-render |
T-STATE-0002 | owned, borrowed, observed, and exposed state view capabilities |
T-STATE-0003 | finite kinds, value spec metadata, JSON-compatible boundary |
T-STATE-0004 | setup-only watch, StateEvent, re-entrant ordering, lifecycle cleanup |
T-STATE-INTERACTION-0001 | interaction event wiring, callback ordering, shared subject identity |
These tests turn state from an “internal variable” into executable state-machine boundaries: when it can be defined, when it can be read or written, who can write it, who can only observe it, and how change records are delivered.
Relationship To Other Specifications
Section titled “Relationship To Other Specifications”Coredefines setup/runtime, JSON value boundaries, and the channel model; State is explicitly not an information channel.Propscarries App Maker configuration into the Component; State expresses the Component’s internal continuity.Eventcarries User interaction into the Component; interaction state uses runtime-managed Event wiring to maintain official interaction state.Feedbackmakes component state and results perceivable to the User; State itself does not render or present anything.Rulecan read state and perform feedback intents, but Rule is an independent API, not part of State.Lifecycledefines when state views are available across setup, runtime callbacks, unmounted, and dispose.