State & Reactivity
kadokit’s reactivity model connects your state to the UI through bindings. When state changes, bound widgets update automatically.
Declaring state
State is declared in a state block at the top level:
state {
temperature type="int" default=22
mode type="string" default="heat"
fan type="bool" default=#false
}There is also a shorthand using KDL type annotations, with the default as the first argument:
state {
temperature (int) 22
mode (string) "heat"
fan (bool) #false
}Supported types
| Type | Example |
|---|---|
int | default=42 |
string | default="hello" |
bool | default=#true |
color | default="#FF6B35" |
array | See Object arrays |
Constraints
state {
brightness type="int" default=70 min=0 max=100
mode type="string" default="heat" enum="heat,cool,auto"
temperature type="int" default=22 read-only=#true
}| Constraint | Effect |
|---|---|
min / max | Integer values are clamped to range on set |
enum | String values must be in the comma-separated list |
read-only | UI-side writes are rejected; only firmware can update the value |
read-only state is how sensor data stays trustworthy: the UI can display it
but never accidentally overwrite it.
Exposing state to expressions
Before state can be referenced in expressions, expose the store with a name:
expose "app"
label { text bind="app.temperature + '°C'" }Any name works (expose "bike" → bike.speed), and multiple expose calls
create aliases for the same store.
One way binding (bind=)
bind= evaluates its expression again whenever any referenced state variable
changes, on any widget property:
label { text bind="app.temperature + '°C'" }
label { text bind="app.alarm ? 'Alert' : 'OK'" }
label { text bind="formatTime(app.seconds)" }
slider { value bind="app.brightness" }
box { hidden bind="!app.show_details" }
box { bg-color bind="app.alarm ? '#FF0000' : '#00FF00'" }State keys must be visible in the binding expression to be tracked. Helper
functions are reactive when their state dependencies are passed as arguments,
like formatTime(app.seconds).
Two way binding (model=)
model= also writes user interaction back to state:
slider { value model="app.brightness" }
switch { checked model="app.dark_mode" }
dropdown { selected model="app.language" }
textarea { text model="app.name" }Two way binding is supported on: slider/arc/bar value,
switch/checkbox checked, dropdown/roller selected, and textarea
text. Using model= on a non-writable property or with a complex
expression is a hard error. Use bind= there.
Expressions
Binding and action expressions use JavaScript syntax:
bind="app.speed * 3.6"
bind="app.name + ' (' + app.role + ')'"
bind="app.speed > 100 ? '#FF0000' : '#00FF00'"
bind="Math.floor(app.uptime / 3600) + 'h'"
bind="formatTime(app.seconds)"In a set action, $self (as the entire expression) is a shorthand for the
triggering widget’s current interactive value; in larger expressions, use the
widget object (widget.value, widget.checked, …):
slider {
on-change { set "brightness" expr="$self" }
// equivalent, and composable:
// on-change { set "brightness" expr="widget.value" }
}See Scripting for the full expression environment.
Computed state
Computed values derive from other state and update reactively:
computed "total" expr="app.price * app.quantity"
computed "status" expr="app.temperature > 30 ? 'Hot' : 'Normal'"
label { text bind="app.total" }Object arrays
Arrays of structured objects support per-field binding and mutation:
state {
rooms type="array" {
- { name "Living Room"; brightness 70; night_mode #false }
- { name "Bedroom"; brightness 40; night_mode #true }
}
}Iterate with for:
for "rooms" as="room" {
label "{room.name}"
slider { value model="room.brightness" }
switch { checked model="room.night_mode" }
}Inside the loop, model="room.brightness" performs per-element writeback:
moving one slider updates only rooms[i].brightness. The 0-based index is
available in expressions.
Mutate arrays from event actions:
on-click { array-insert "rooms" expr="{ name: 'New Room', brightness: 50 }" }
on-click { array-remove "rooms" expr="index" }
on-click { array-move "rooms" from=0 to=2 }Batching
State changes are coalesced automatically: they are deferred and flushed once per display refresh frame (~16 ms at 60 FPS), so a burst of updates costs one UI pass. Firmware code can also batch explicitly. See Advanced → Firmware integration.