Signals: state that updates the UI — signal, computed, effect
A signal holds a piece of app state — a counter, the cart, the selected tab. A text, a style, a
list or an effect that reads it updates by itself when it is written: no re-render and no
label.text = …, the write lands on exactly the UI properties that read it.
- update a text or a color on screen when a value changes
- work out a total or a filtered list from other state (
computed) - show a list that updates when an item is added or removed
- show or hide part of a screen on a toggle
- switch a button's look while it is selected or done (
.class) - run code every time a value changes (
effect) - keep a setting or the cart after the app restarts (
.stored(key))
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 }),
)
screen.open()No manual label.text = … anywhere — the bindings re-run when count changes.
Hold a value and change it: signal
App state — a count, a form field, a list — lives in a signal: create it with its initial value,
read and write it through .value.
const count = signal(0) // Signal<number>
count.value // read: subscribes inside an effect, a computed or a UI binding
count.value = 1 // write: whatever read it re-runs, once, on the next microtask
count.peek() // read WITHOUT subscribing
const user = signal<User>() // Signal<User | undefined>, starts undefined- Writes are batched: several
.value =writes in one tick re-run each reader once, on the next microtask. Writing an equal value (Object.is) does nothing. - A signal sees writes, not changes inside its value:
items.value.push(x)tells nobody — write a new array (items.value = [ ...items.value, x ]).
Work out a total or a filtered list from other state: computed
A total, a count or a filtered list that follows other signals is a computed: it is never
written, only read, and it is always up to date.
const items = signal([ { name: "Tea", price: 3 }, { name: "Cake", price: 5 } ])
const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0))
UIScreen(
UIColumn(
UIText(() => `Total: ${total.value}`).style({ color: "white", fontSize: 20 }),
UIButton(UIText("Add coffee").style({ color: "white" }))
.style({ height: 40, borderRadius: 8, justifyContent: "center", bgColor: "#FF4032" })
.onClick(() => { items.value = [ ...items.value, { name: "Coffee", price: 4 } ] }),
).style({ gap: 8, p: 16 }),
).open()- Lazy and cached: the function runs on the first read, and again on a read after one of its sources changed — even before the next flush, so a read right after a write is fresh.
.valueis read-only;.peek()reads without subscribing. Acomputedhas no.stored.
Run code every time a value changes: effect
For a side effect that must follow a value — a log, a request, a call into the engine —
effect(fn) runs fn at once and again after every change of what it read. It returns the
function that stops it.
const query = signal("")
const stop = effect(() => {
console.log(`search: ${query.value}`) // runs now, then once after each change
})
UIScreen(
UIColumn(
UIInput().style({ placeholder: "Search" }).onChange((text) => { query.value = text }),
UIButton(UIText("Stop logging")).onClick(() => stop()),
).style({ gap: 8, p: 16 }),
).open()- The first run happens inside
effect(); an error thrown there reaches the caller. A later run that throws is reported withconsole.error— the other effects still run, and so do later flushes. - Dependencies are tracked per run: a branch that stops reading a signal unsubscribes from it.
- An effect lives for the app run until it is stopped. UI bindings need no stopping — they live and die with their element.
- An effect that writes a signal it reads re-runs itself: the flush gives up after 10 000 runs
with a
console.warnand drops what is left.
Update a text or a style when a value changes: UI bindings
To update a label, a color or a size when state changes, give the element a function instead of a
value — UIText(() => …), .style({ prop: () => … }). It runs once at creation, so the element
holds a value from the start, and again whenever a signal it read changes.
const liked = signal(false)
UIScreen(
UIColumn(
UIButton(UIText(() => (liked.value ? "Liked" : "Like")).style({ color: "white" }))
.style({
height: 44, borderRadius: 22, justifyContent: "center",
width: () => (liked.value ? 140 : 100), // the size follows the state
bgColor: () => (liked.value ? "#e11d48" : "#475569"), // and so does the color
})
.named("like")
.onClick(() => { liked.value = !liked.value }),
).style({ p: 16 }),
).open()- Any primitive style value binds: a color, a size,
opacity,display, atransformstring (transform: () => `translateY(${y.value}px)`). - 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 remove the binding — the next signal change overwrites it. el.style.prop = () => …binds too at run time, but the setter is typed for plain values — use the.style({ … })form in typed code.- Top level only: a function inside a
$classblock ($pressed: { … },$landscape: { … }) is not a binding. - Setting
.texton a boundUITextworks until the next signal change overwrites it.
A list that updates when items are added or removed: reactive children
To show a list that changes with state — todos, search results, a chat — make its container's only
argument a function: UIColumn(() => items.value.map(…)). It re-runs when a signal it read
changes, and only the difference reaches the screen.
type Todo = { title: string }
const todos = signal<Todo[]>([ { title: "Buy milk" } ])
UIScreen(
UIColumn(
UIButton(UIText("Add a task")).onClick(() => {
todos.value = [ ...todos.value, { title: `Task ${todos.value.length + 1}` } ] // a new array
}),
UIColumn(() => todos.value.map((todo) => UIText(todo.title))),
).style({ gap: 8, p: 16 }),
).open()The container reconciles by element identity: departed nodes are removed, new ones inserted at
their position, unchanged ones stay mounted. A plain .map(…) inside a children function is
memoized per item by the compiler (through the internal __uiMap helper — never call it
yourself), so an item object that stays in the list keeps its element. That gives the rules:
- Item identity is the object reference. Write a new array that reuses the unchanged item
objects (
[ ...items.value, added ],.filter(…)). Rebuilding every item (items.value.map(i => ({ ...i }))) renders every row again. - The render callback runs once per new item (like a component), not once per change — no side effects there that expect a run per update.
- A container with function children is owned by that binding — don't also call
.append()/.remove()on it. - A reordered item is removed and inserted again: it remounts.
- For hundreds of rows use
UIVirtualizedList; function children are for small and medium lists.
el.setContent(() => …) binds the same way.
Show or hide part of a screen
To show a part only while a value says so, put flag.value && element in a children function: a
falsy entry is left out of the container.
const expanded = signal(false)
const toggle = UIButton(UIText(() => (expanded.value ? "Hide details" : "Show details")))
.onClick(() => { expanded.value = !expanded.value })
const details = UIText("Weight 2 kg, size 30 × 20 cm")
UIScreen(
UIColumn(() => [ toggle, expanded.value && details ]).style({ gap: 8, p: 16 }),
).open()To keep the element mounted while it is hidden, bind its display instead:
details.style({ display: () => (expanded.value ? "flex" : "none") }).
Switch a look on and off: reactive classes el.class
To change how an element looks while a value is true — a done task, a selected tab — declare the
look as a $-class and bind the class to the value; a duration on the class
fades the change.
const done = signal(false)
UIScreen(
UIColumn(
UIButton(UIText("Water the plants").style({ color: "white" }))
.style({
height: 44, px: 16, borderRadius: 8, bgColor: "#334155",
$done: { opacity: 0.4, duration: 150 }, // the look of the class, faded in
})
.class({ done: () => done.value }) // on while done.value is true
.named("plants")
.onClick(() => { done.value = !done.value }),
).style({ p: 16 }),
).open()row.class.done = () => … is the single-key form. A later plain write (row.class.done = false)
replaces the binding — last write wins.
Keep state after the app restarts: .stored(key)
A stored signal keeps small app state across restarts — a setting, the cart, the last tab;
documents and files go to files on desktop and iOS (Files & share),
data shared between devices or users to the backend database.
type Line = { name: string, price: number }
const cart = signal<Line[]>([]).stored("cart") // what the last run saved; [] on the first launch
UIScreen(
UIColumn(
UIText(() => `In the cart: ${cart.value.length}`).style({ fontSize: 20 }),
UIButton(UIText("Add tea")).onClick(() => {
cart.value = [ ...cart.value, { name: "Tea", price: 3 } ] // saved at once
}),
UIButton(UIText("Empty the cart")).onClick(() => { cart.value = [] }),
).style({ gap: 8, p: 16 }),
).open()signal(initial).stored(key)starts from what the last run saved underkey; on the first launch the initial value stands (nothing is saved until the first write).- Every write is saved at once, as JSON in
localStorageunder the same key —localStorage.getItem("cart")reads it. Writingundefinedremoves the key. - JSON values only: a
Datecomes back as its ISO string, a class instance as a plain object — keep a timestamp (Date.now()), not aDate. - Write a new array or object: one changed in place is neither shown nor saved.
- What is under the key is taken as it is when it reads as JSON, even in another shape; text that is not JSON is ignored and the initial value stands. When the shape of what you keep changes, change the key. One key per signal.
- A
computedhas no.stored: keep its sources, it follows them.
a test run (lecodes render / lecodes test) keeps stored signals in memory for that
run only — every run starts from the initial values.
Bindings without () =>: what the compiler wraps
In project code the () => may be left out where a signal is read directly: a UIText argument,
a top-level .style({ … }) value and a .class({ … }) value that read a signal's .value are
wrapped into bindings when the app is compiled.
const count = signal(0)
const active = signal(false)
UIScreen(
UIColumn(
UIText(`Count: ${count.value}`), // compiled to UIText(() => `…`)
UIButton(UIText("+1"))
.style({ bgColor: active.value ? "#f43f5e" : "#334155", $on: { opacity: 0.4 } }) // style binding
.class({ on: active.value }) // compiled to a class binding
.named("plus")
.onClick(() => { count.value++; active.value = !active.value }),
).style({ gap: 8, p: 16 }),
).open()The compiler recognizes a read of a variable this file assigned from signal() / computed(). A
signal imported from another file, passed in as a parameter, reached through an object
(todo.done.value) or read inside a helper (UIText(fmt())) is not detected — the value is
read once and never updates. Write the arrow there: UIText(() => fmt()).
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 screen to change synchronously after a write (bindings re-run on a microtask)
count.value = 5; /* label not repainted yet on this line */See also
- UI styling — the style props bindings can target
- Classes —
$-classes and their transitions - Content elements —
UIText - Storage —
localStorage, under stored signals - animate — time-based tweens (signals are state-based, not time-based)