signal, computed & effect
Fine-grained reactivity for app state. A signal holds a value; reading .value inside an
effect, a computed, or a UI binding subscribes it, and writing .value re-runs exactly
those subscribers. There is no virtual DOM and no re-render: a signal write lands as a precise
property update on the retained UI tree.
At a glance
const count = signal(0)
const label = computed(() => `Count: ${count.value}`)
const screen = UIScreen([
UIColumn([
UIText(() => label.value) // text binding — updates by itself
.style({ color: "white", fontSize: 20 }),
UIButton([ UIText("+1").style({ color: "white" }) ])
.style({
height: 40, borderRadius: 8, justifyContent: "center",
bgColor: () => (count.value > 9 ? "#22c55e" : "#FF4032"), // style binding
})
.onClick(() => count.value++),
]).style({ gap: 8, p: 16 }),
])
Router.push(screen)No manual label.text = … anywhere — the bindings re-run when count changes.
API
const s = signal(0) // Signal<number>
s.value // read (subscribes inside an effect/computed/binding)
s.value = 1 // write — notifies subscribers, batched per microtask
s.peek() // read WITHOUT subscribing
const c = computed(() => s.value * 2) // Computed<number> — lazy, cached until a dep changes
c.value
const dispose = effect(() => { // runs now, and again after any read signal changes
console.log("count is", s.value)
})
dispose() // stop it; undisposed effects live for the app run- Writes are batched: several
.value =writes in one tick produce one re-run per affected effect (on the next microtask). Writing an equal value (Object.is) is a no-op. - Dependencies are tracked per run: a branch that stops reading a signal unsubscribes from it.
effectreturns its dispose function. UI bindings need no disposal — they live and die with their element.
UI bindings
Wherever a UI element takes a text string or a primitive style value, a () => value function
creates a binding instead:
UIText(() => `${items.value.length} items`) // reactive text
el.style({ opacity: () => (open.value ? 1 : 0) }) // reactive style prop (merge form)
el.style.transform = () => `translateY(${y.value}px)` // reactive single-prop writeRules:
- Bindings run once synchronously at creation, so the element holds a concrete value immediately.
- A later
.style({ prop: staticValue })replaces the binding on that key; a later.style({ prop: () => … })re-binds it. (A directel.style.prop = staticValuewrite does not dispose a binding — the next signal change overwrites it.) - Top level only: function values inside nested state blocks (
onPressed: { … },onLandscape: { … }) are not bindings. - Setting
.textmanually on a boundUITextworks but is overwritten on the next signal change.
Reactive children
A container also accepts a function in children position — the list re-renders itself when a signal it read changes:
const todos = signal([ { title: "one", done: false } ])
UIColumn(() => todos.value.map(todo =>
UIRow([ UIText(todo.title) ])
))
// conditional rendering — falsy entries just disappear:
UIColumn(() => [ header, expanded.value && details ])
todos.value = [ ...todos.value, { title: "two", done: false } ] // one node inserted, rest untouchedUpdates are minimal: the runtime diffs by element identity — departed nodes are removed, new
ones inserted at their position, unchanged ones stay mounted. Plain .map(…) inside a children
function is memoized per item by the compiler (via the internal __uiMap helper — never call it
yourself), so an item object that stays in the list keeps its element across updates. That gives
the rules:
- Item identity is the object reference. Update lists by writing a new array that reuses
unchanged item objects (
[ ...items.value, added ],.filter(…)). Rebuilding every item object each write (items.value.map(i => ({ ...i }))) forces a full re-render. - The render callback runs once per new item (like a component), not once per change — don't put side effects there expecting per-update runs.
- A container with function children is owned by that binding — don't also call
.append()/.remove()on it manually. - A reordered item is remove+inserted (its host state — input focus, scroll — resets).
- For hundreds of rows use
UIVirtualizedList; function children are for small-to-medium lists.
el.setContent(() => …) binds the same way.
Reactive classes: bindClass
Bind a $-style-class to a signal — transitions declared on the class apply
automatically:
row.style({ $done: { opacity: 0.4, duration: 150 } })
.bindClass("done", () => todo.done.value)Automatic wrapping (what the compiler does for you)
In project code, a UIText text argument or a top-level style value that reads a signal's
.value is wrapped into a binding at compile time — so the explicit () => form is optional in
those positions:
UIText(`Count: ${count.value}`) // compiled to UIText(() => `…`)
btn.style({ bgColor: active.value ? "#f43" : "#333" }) // compiled to a style bindingThe () => forms remain valid and identical in behavior. Reads inside your own helper functions
(UIText(fmt())) are not detected — use the explicit arrow there.
Pitfalls
// ✗ reading .value into a plain variable first — the read happens once, nothing updates later
const n = count.value
UIText(`Count: ${n}`)
// ✓ read .value in the text expression itself (auto-wrapped), or write the binding explicitly
UIText(`Count: ${count.value}`)
UIText(() => `Count: ${count.value}`)
// ✗ a signal holding an array mutated in place — same reference, no notification
items.value.push(x)
// ✓ write a new reference
items.value = [ ...items.value, x ]
// ✗ expecting the DOM to change synchronously after a write (updates flush on a microtask)
count.value = 5; /* label not repainted yet on this line */See also
- UI styling — the style props bindings can target
- Content elements —
UIText - animate — time-based tweens (signals are state-based, not time-based)