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

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 }),
)
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.

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

TypeScript
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.
  • .value is read-only; .peek() reads without subscribing. A computed has 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.

TypeScript
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 with console.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.warn and 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.

TypeScript
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, a transform string (transform: () => `translateY(${y.value}px)`).
  • 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 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 $class block ($pressed: { … }, $landscape: { … }) is not a binding.
  • Setting .text on a bound UIText works 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.

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

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

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

TypeScript
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 under key; on the first launch the initial value stands (nothing is saved until the first write).
  • Every write is saved at once, as JSON in localStorage under the same key — localStorage.getItem("cart") reads it. Writing undefined removes the key.
  • JSON values only: a Date comes back as its ISO string, a class instance as a plain object — keep a timestamp (Date.now()), not a Date.
  • 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 computed has no .stored: keep its sources, it follows them.
Note

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.

TypeScript
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

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 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)