LeCodesdocs

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

TypeScript
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

TypeScript
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.
  • effect returns 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:

TypeScript
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 write

Rules:

  • 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 direct el.style.prop = staticValue write 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 .text manually on a bound UIText works 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:

TypeScript
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 untouched

Updates 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:

TypeScript
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:

TypeScript
UIText(`Count: ${count.value}`)                    // compiled to UIText(() => `…`)
btn.style({ bgColor: active.value ? "#f43" : "#333" })   // compiled to a style binding

The () => forms remain valid and identical in behavior. Reads inside your own helper functions (UIText(fmt())) are not detected — use the explicit arrow there.

Pitfalls

TypeScript
// ✗ 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