Skip to content
State & Reactivity

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

TypeExample
intdefault=42
stringdefault="hello"
booldefault=#true
colordefault="#FF6B35"
arraySee 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
}
ConstraintEffect
min / maxInteger values are clamped to range on set
enumString values must be in the comma-separated list
read-onlyUI-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.