Chapter 15: Reactive UI
⚠ Planned — not in Loup 1.25. This chapter describes the design in the Language Reference (§2-4 Reactive UI: §2-4.1 signals, §2-4.2 components, §2-4.3 mounting, §2-4.6 the
uifence, §2-4.8 the desktop backend). The toolchain does not implement it yet, so its listings are design sketches and are not verified.
What you'll learn: the planned design of Loup's reactive UI — signals, derived values and effects, components written as functions that return a View, the cross-platform ui fence, and mounting a view in a window.
Status in Loup 1.25
None of the ui modules ship. from ui import (signal); fails with 'ui' is not a standard-library module, and signal, derived, effect, View, ComponentRegistry and mount are undefined. A (ui) template fence does compile, but only as an ordinary text template: rendering it returns its markup as a string, not a View. No desktop or browser renderer exists.
What you can do today is generate HTML text with html template fences (Chapter 12) and serve it with the net.http server module; Chapter 18 builds command-line tools, which is where most scripts end up.
The Design in Brief
The UI system has three layers (§2-4): a universal core, ui, with signals and the component model; a web backend, ui.web, for browsers and server-side rendering; and a desktop backend, ui.desktop, for native windows. A host.scripted file uses the core and the desktop backend; ui.web is for the browser and service targets and is not available to scripts (§1-2.4.3).
Signals, Derived Values and Effects
A signal is a value that records who reads it and notifies them when it changes (§2-4.1). derived(fn) is a read-only signal computed from others; effect(fn) runs fn once immediately and again whenever a signal it read changes:
@mode(host.scripted)
from ui import (signal, derived, effect);
let celsius = signal(20.0);
let fahrenheit = derived(func() => celsius.get() * 9.0 / 5.0 + 32.0);
let watcher = effect(func() {
println(i"{$ celsius.get() $} C = {$ fahrenheit.get() $} F");
});
# prints "20 C = 68 F" immediately
celsius.set(30.0);
# prints "30 C = 86 F"
celsius.update(func(c: float) => c + 5.0);
# prints "35 C = 95 F"
watcher.dispose();
celsius.set(0.0);
# prints nothing: the effect was disposed
Listing 15-1: A signal, a derived signal and an effect (design sketch)
The API is small: Signal[T] has get(), set(value) and update(fn); effect returns an EffectHandle with dispose().
Components and the ui Fence
A component is a function that returns a View (§2-4.2). Its body runs once, when the component is created, so local signals hold its state. The markup is written in a ui template fence whose tags — Column, Row, Text, Button, TextField, Checkbox and the rest of §2-4.6 — map to native widgets or to HTML, depending on the backend. Uppercase tags are looked up in a ComponentRegistry, named in the fence's components= descriptor:
@mode(host.scripted)
from ui import (signal, View, ComponentRegistry);
import ui.primitives;
let registry = ComponentRegistry.of(ui.primitives.components);
func Counter(start: int): View {
let count = signal(start);
return ```(ui):(components=registry)
<Column spacing={$ 8 $}>
<Text>Count: {$ count.get() $}</Text>
<Row spacing={$ 8 $}>
<Button onclick={$ func() { count.update(func(n: int) => n + 1); } $}>
<Text>+1</Text>
</Button>
<Button onclick={$ func() { count.set(start); } $}>
<Text>Reset</Text>
</Button>
</Row>
</Column>
```;
}
Listing 15-2: A counter component (design sketch)
Reading count.get() inside the fence makes the text depend on count; clicking a button changes the signal and the runtime re-renders only what depends on it. Event handlers are Loup closures, never strings of JavaScript (E_INLINE_JS_EVENT_HANDLER). The fence directives of Chapter 12 — {% if %}, {% for %}, {% match %} — work inside ui fences, and {% suspend fallback={$ … $} %} shows a placeholder while an async component's Task[View] resolves (§2-4.5).
Input widgets bind to a signal directly: TextField(value: Signal[string]), Checkbox(checked: Signal[bool]).
Mounting
mount(view, target) attaches a view to a rendering root and returns a handle with unmount() (§2-4.3). runtime.ui.rootMount() gives the platform's default root, so one entry point works on every backend. A script would open a desktop window:
@mode(host.scripted)
from ui import (signal, mount, View, ComponentRegistry);
import ui.primitives;
let registry = ComponentRegistry.of(ui.primitives.components);
func TodoApp(): View {
let items = signal(list[string]());
let draft = signal("");
return ```(ui):(components=registry)
<Column spacing={$ 12 $}>
<Row spacing={$ 8 $}>
<TextField value={$ draft $} placeholder="Add item..." />
<Button onclick={$ func() {
items.update(func(xs: list[string]) => xs + [draft.get()]);
draft.set("");
} $}>
<Text>Add</Text>
</Button>
</Row>
{% for (item in items.get()) %}
<Text>{$ item $}</Text>
{% end %}
</Column>
```;
}
mount(TodoApp(), runtime.ui.rootMount());
Listing 15-3: A to-do list mounted at the platform's default root (design sketch)
The list is updated by producing a new list (xs + [draft.get()]) rather than mutating the old one; treating the values held in signals as immutable is the intended style.
Custom elements (@webcomponent), server-side rendering and hydration belong to the web backend; see The Loup Web Book, which describes them as planned as well.
Summary
- Reactive UI is specified in §2-4 and not implemented in Loup 1.25: the
uimodules do not exist, and a(ui)fence renders only as text. - The design:
signal,derivedandeffectfor state; components are functions returningView, written with theuifence and aComponentRegistry;mount(view, target)attaches a view, withruntime.ui.rootMount()as the portable target. - Scripts are meant to use the core
uimodule and the desktop backend;ui.webis for browser and service targets. - Until it ships, generate HTML with
htmlfences (Chapter 12).