Chapter 16: Reactive UI
⚠ Planned — not in Loup 1.25. This chapter describes the design in the Language Reference (§2-4.1–§2-4.7, with the template fences of §1-16). The toolchain does not implement it yet, so its listings are design sketches and are not verified.
The Reference specifies a fine-grained reactive UI: signals hold state, components are functions that return a View, and a browser runtime renders views to the DOM and updates only the parts that read a changed signal. A component's body runs once, as in SolidJS, not on every render as in React.
None of this ships in Loup 1.25: there is no ui module, no View, no mount and no browser runtime. The import alone stops loup run:
main.lpweb:3:6: error[E0001]: 'ui' is not a standard-library module (namespace 'ui' is reserved for the standard library)
What works today is rendering an html fence to a string (Chapter 14).
Signals
ui declares three functions (§2-4.1): signal(initial) makes a writable Signal[T] with get(), set(value) and update(fn); derived(fn) makes a read-only signal recomputed when the signals fn reads change; effect(fn) runs fn now and again after each change to a signal it read, and returns an EffectHandle whose dispose() stops it.
@mode(web.compiled)
from ui import (signal, derived, effect, Signal, EffectHandle);
func cartTotals(): void {
let price: Signal[int] = signal(120);
let qty = signal(3);
let total = derived(func(): int => price.get() * qty.get());
let handle: EffectHandle = effect(func(): void {
println(i"total is {$ total.get() $}");
}); # runs now: total is 360
qty.set(4); # total is 480
price.update(func(p: int): int => p - 20); # total is 400
handle.dispose();
qty.set(5); # no output
}
Listing 16-1: Signals, a derived value and an effect (design sketch)
A get() inside reactive code records a dependency; a write re-evaluates the dependents. Earlier drafts of this book called derived computed; the Reference's name is derived.
Components
A component is a function returning a View, the immutable UI description that html and ui fences produce (§2-4.2, §2-4.4). Components have no lifecycle methods: setup runs once, and signal reads inside the fence keep the output current.
@mode(web.compiled)
from ui import (signal, View);
pub func Counter(start: int): View {
let count = signal(start);
return ```(html):(none)
<div class="counter">
<p>Count: {$ count.get() $}</p>
<button onclick={$ func(): void { count.update(func(n: int): int => n + 1); } $}>+1</button>
<button onclick={$ func(): void { count.set(start); } $}>Reset</button>
</div>
```;
}
Listing 16-2: A counter component (design sketch)
An on* attribute takes a Loup callable, not JavaScript. The compiler replaces it with data-loup-ev="click:__ev_…" and registers the closure; the runtime attaches it with addEventListener, so no inline script reaches the page and no 'unsafe-inline' CSP is needed (Chapter 19). The Reference specifies E_INLINE_JS_EVENT_HANDLER for a string handler such as onclick="count++", and E_COMPONENT_NOT_FOUND / E_COMPONENT_NOT_VIEW for bad component tags.
Mounting
mount(view, target) attaches a View to a root and returns a MountHandle with unmount() (§2-4.3). On the browser the target is a selector string or an Element.
@mode(web.scripted)
from ui import (mount);
from counter import (Counter);
let handle = mount(Counter(start = 0), "#root");
Listing 16-3: The entry file (design sketch)
The entry is web.scripted because the bootstrap expects a top-level mount call (§1-20.6), and a web.compiled file rejects top-level statements with E_TOPLEVEL_CONTROL_AOT (Chapter 2). The Reference does not yet say how a web.compiled entry registers its mount.
The ui fence
A ui fence builds a View from platform-neutral PascalCase primitives that the web backend maps to HTML and CSS and a desktop backend to native widgets (§2-4.6): Row, Column, Stack, Scroll, Spacer, Divider, Padding; Text, Button, Image, Icon, Canvas; and the inputs TextField, Checkbox, Slider, Select[T], each bound two-way to a Signal.
@mode(web.compiled)
from ui import (signal, ComponentRegistry, View, TextStyle);
import ui.primitives;
pub func TodoPanel(): View {
let registry = ComponentRegistry.of(ui.primitives.components);
let items = signal(["buy milk"]);
let draft = signal("");
return ```(ui):(components=registry)
<Column spacing={$ 12 $}>
<Text style={$ TextStyle(size = 20, weight = 600) $}>Today</Text>
<Row spacing={$ 8 $}>
<TextField value={$ draft $} placeholder="Add an item" />
<Button onclick={$ func(): void {
items.update(func(xs: list[string]): list[string] => xs + [draft.get()]);
draft.set("");
} $}>
<Text>Add</Text>
</Button>
</Row>
{% for (item in items.get()) %}
<Text>{$ item $}</Text>
{% end %}
</Column>
```;
}
Listing 16-4: A to-do panel in a ui fence (design sketch)
The components=registry descriptor names a ComponentRegistry in lexical scope (§2-4.2). The sketch builds it inside the function because a top-level let in web.compiled needs a constant initializer. A lowercase tag in a ui fence is E_UI_LOWERCASE_TAG.
Async components
A component may be an async func returning Task[View]; {% suspend fallback={$ … $} %} shows the fallback until every async component inside the block has finished, and several of them load concurrently (§2-4.5). Note that async functions run eagerly on today's interpreter (Chapter 15).
Summary
- Reactive UI (Language Reference §2-4) is not in Loup 1.25: no
uimodule,View,mountor browser runtime. signal,derivedandeffectare the primitives; reads are dependencies, writes re-evaluate dependents.- Components are functions returning
View; they run once, and event handlers are Loup closures wired throughdata-loup-ev. mount(view, "#root")returns aMountHandle; the specified entry is a top-level call, which onlyweb.scriptedallows.uifences use PascalCase primitives from aComponentRegistry;{% suspend %}covers async components.