Reactive state
std::reactive is vilan’s state layer. If you’ve used signals in Solid or
Preact, you’ll be at home immediately. If you’re coming from React, think
of a signal as a piece of state that components subscribe to directly —
there is no re-render, no dependency array, no memoization dance. When a
signal changes, exactly the code that watches it runs.
Four ideas make up the layer, and this chapter takes them in order:
- Signals hold values.
- Effects run code when signals change.
- Owners decide when effects die.
- Turns decide when changes become visible.
The UI layer, the rpc mirrors, and the router are all built on these, so this chapter pays for itself quickly.
import std::print;
import std::reactive::{ Signal, Owner, run_with_owner };
fun main() {
let count = Signal::new(0);
let owner = Owner::new();
run_with_owner(owner, || {
count.effect(|value: i32| print(value));
});
count.set(1);
count.set(2);
}
Signals
A Signal<T> is a mutable cell whose readers can subscribe to changes.
Signal::new(value: T): Signal<T> // a fresh signal
signal.get(): T // current value
signal.set(value: T) // write + notify subscribers
signal.set_with(transform: sync |T| T) // read-modify-write in one step
Signals hold values. vilan copies, so get hands you a copy, and the
only way to change what subscribers see is set or set_with. To update
a list inside a signal, transform it:
import std::print;
import std::reactive::Signal;
fun main() {
let items: Signal<List<str>> = Signal::new([]);
items.set_with(|list| {
mut updated = list;
updated.push("first");
updated
});
print(items.get().len());
}
(If you tried items.get().push("first"), you’d be mutating a copy. The
memory model chapter explains why that’s a
feature.)
Derived state: map, combine, flatten
Build state as a graph and let it recompute itself:
-
signal.map(transform)gives a signal of the transformed value:import std::print; import std::reactive::Signal; fun main() { let count = Signal::new(2); let doubled = count.map(|n: i32| n * 2); print(doubled.get()); count.set(5); print(doubled.get()); } -
combine((a, b, …))gives a signal of the tuple of several signals’ values. It fires when any of them changes. Takes two or more. -
nested.flatten()on aSignal<Signal<U>>follows whichever inner signal is current, and detaches from a replaced one.
import std::print;
import std::reactive::{ Signal, combine };
fun main() {
let first = Signal::new("Ada");
let last = Signal::new("Lovelace");
let full = combine((first, last)).map(|pair: (str, str)| {
let (a, b) = pair;
a + " " + b
});
print(full.get());
first.set("Grace");
print(full.get());
}
A named function can stand in for the closure (signal.map(parse)) —
see functions & closures.
Reacting: effect and sub
Two ways to run code on change. Use effect by default.
signal.effect(observer)runs the observer now with the current value, re-runs it on every change, and cleans itself up automatically when its surrounding UI (or other owner) goes away. Nothing to remember.signal.sub(observer): Subscriptionis the manual version. It fires only on later changes, and you keep theSubscriptionand calldispose()on it yourself.
Ownership: who cleans up
Every effect is a subscription, and subscriptions must die when the
thing that created them goes away — otherwise a page you navigated off
keeps reacting forever. That’s a memory leak in any reactive system.
vilan’s answer is owners, and the good news is that in normal app
code you never manage them: the UI layer creates owners exactly where
subtrees can die (a mounted root, a list row, a conditional block), and
every effect you create automatically registers with the nearest one.
For tests, or when you’re building your own machinery:
Owner::new()makes an owner;owner.dispose()disposes everything registered with it.run_with_owner(owner, || …)runs a block with that owner ambient. Everyeffectinside — however deep in function calls — registers into it.get_owner()reads the ambient owner, e.g. to attach custom cleanup withowner.defer(…).
import std::print;
import std::reactive::{ Signal, Owner, run_with_owner };
fun main() {
let source = Signal::new(0);
let owner = Owner::new();
run_with_owner(owner, || {
source.effect(|value: i32| print(value));
});
source.set(1);
owner.dispose();
source.set(2); // not printed: the effect died with its owner
}
Creating reactive state outside any owner is a compile error. That
sounds strict, but it’s the property that makes leaks impossible by
construction, and in practice mount_root already gave you an owner
before your first line of UI code ran.
Going deeper. Ownership flows through the
contextmechanism (functions & closures): theowner_scopecontext carries the current owner, and closure parameters markedcontext owner_scopereceive it invisibly.compruns a block under a fresh owner and returns(result, owner)— it’s the primitive undermount_root.
Turns: when changes become visible
If an event handler sets five signals, you want watchers to see the
final state once, not five intermediate states. vilan batches writes
into turns. Inside a turn, set just records. When the turn
settles, each affected watcher runs once with the final values:
click ──▶ the handler runs inside a fresh turn
│
│ count.set(1) ┐
│ items.set(…) │ writes are recorded, not delivered
│ count.set(2) ┘
│
└─ the handler's sync part ends → the turn SETTLES
│
├─▶ the count watcher runs once (sees 2 — never 1)
└─▶ the items watcher runs once
one turn = one consistent wave, no matter how many writes
You mostly never manage turns, because the framework opens them at its
boundaries: every UI event handler runs in one, every mount_root build
runs in one, and every rpc handler on the server runs in one. This is
like React’s automatic batching, generalized.
For the rare explicit cases:
turn(policy, || …) // run a block in a fresh turn; an awaiting body HOLDS it
batch(|| …) // join the current turn, or create one
flush() // drain the ambient turn early
Going deeper. Suspension is where the shapes differ. An explicit
turnadapts to its body: a synchronous body settles when it ends (the atomic turn), and an awaiting body holds every notification — before the first await and in every continuation — until the whole body finishes, then settles once: a true transaction. A boundary turn around a fire-and-forget handler (a UI event) can’t wait for the handler’s continuations, so it settles at the end of each synchronous stretch — one wave per segment:handler: |── writes ──|─── await ───|── writes ──| boundary turn: settle ▲ settle ▲ (a UI event) (wave 1) (wave 2) turn, awaiting body: settle ▲ (one wave)Writes that land after a turn already settled (from spawned work) are grouped per continuation segment and drained in a microtask, so you never observe half a wave.
Optimistic writes and local-first drafts
Two ready-made lifecycles for “update the UI now, confirm with the server after”. They differ in what happens on failure, and the difference is the point:
optimistic(signal, value, commit) paints the value immediately,
runs your async commit, and on failure rolls back. Use it for
one-shot actions like a delete button — if the delete failed, the row
should come back.
draft(initial, commit) is for editing. It keeps the user’s text
on failure (rolling back mid-typing would eat their input) and retries
naturally on the next push. Bind an input to a draft and every keystroke
can safely commit through an rpc:
struct Draft<T> {
local: Signal<T>, // bind inputs to this
state: Signal<DraftState>, // Synced | Dirty | Failed(str)
…
}
draft(initial: T, commit: async |T| Option<str>): Draft<T>
draft.push(value) // set local + spawn the commit (never waits on the wire)
draft.adopt(remote) // fold in a remote change
The commit closure returns None on success or Some(reason) on
failure, so an rpc-calling closure drops straight in.
The whole lifecycle in one picture — note that the input never waits on
the wire, and every remote change funnels through adopt’s three rules:
you type ──▶ local (Signal) ──▶ the input shows it INSTANTLY
│
└─ push: spawn the commit ──▶ rpc ──▶ server
│
the mirror broadcasts ◀──────┘
│
adopt(remote):
├─ same as last synced? an ECHO — do nothing
├─ local has no edits? take the remote value
└─ local is DIRTY? your text wins for now
import std::print;
import std::reactive::{ draft, Draft, DraftState };
import std::option::Option::{ self, Some, None };
import std::shared::Shared;
fun main() {
let saved: Shared<List<str>> = Shared::new([]);
let name = draft("seed", |value: str| {
saved.write().push(value);
None
});
name.push("edit"); // local is "edit" immediately
print(name.local.get());
name.adopt("edit"); // the server echoing it back: no-op
name.adopt("remote-edit"); // a genuine remote change: adopted (local is clean)
print(name.local.get());
}
Going deeper.
pushis per-keystroke safe: a generation counter means a slow older commit that lands late is discarded rather than clobbering a newer one.adoptfollows three rules — an echo of your own push changes nothing, a clean local adopts the remote edit, and a dirty local wins (last-write-wins: the remote value is remembered so your eventual push knowingly overwrites it). The reactive reference states all of it precisely, andbind_draftin Building UI is the input-side wiring.
Keyed reconciliation
reconcile(old_keys, old_items, new_items, key) computes a minimal
update plan for keyed lists (keep this row, refresh that one, these are
gone). It’s the pure engine underneath ui’s bind_each. You’d only
call it directly to build your own list-rendering primitive.
Traps
subgives you aSubscriptionto dispose manually. Prefereffectand let the owner handle it.- Disposal stops future deliveries. A watcher already queued in the currently-settling turn may fire one final time.
- Derived signals (
map/combine) live as long as their sources, by design. They don’t need owners, and they don’t leak into disposed subtrees.