The Loup Web Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: The Web Modes
  3. Chapter 3: Variables and Data Types
  4. Chapter 4: Functions
  5. Chapter 5: Control Flow
  6. Chapter 6: Collections
  7. Chapter 7: Structs
  8. Chapter 8: Enums and Pattern Matching
  9. Chapter 9: Classes and Objects
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error Handling
  13. Chapter 13: Modules and Packages
  14. Chapter 14: Template Fences — HTML and CSS
  15. Chapter 15: Async and Concurrency
  16. Chapter 16: Reactive UI
  17. Chapter 17: Web Components
  18. Chapter 18: DOM, Storage, Workers, and JavaScript Interop
  19. Chapter 19: SSR, Hydration, and Deployment
  20. Chapter 20: Memory Safety in Web Apps

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.

Fragment — not a complete program
@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.

Fragment — not a complete program
@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.

Fragment — not a complete program
@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.

Fragment — not a complete program
@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 ui module, View, mount or browser runtime.
  • signal, derived and effect are the primitives; reads are dependencies, writes re-evaluate dependents.
  • Components are functions returning View; they run once, and event handlers are Loup closures wired through data-loup-ev.
  • mount(view, "#root") returns a MountHandle; the specified entry is a top-level call, which only web.scripted allows.
  • ui fences use PascalCase primitives from a ComponentRegistry; {% suspend %} covers async components.