Reactivity
Verve's reactive core is three primitives — Signal, Effect, and Owner — shared verbatim between the server and the WASM client. There is no virtual-DOM diffing: a signal write touches exactly the DOM nodes that depend on it.
Signals
A Signal(T) holds a value. Reading it inside an effect subscribes the effect; writing notifies every subscriber:
const sig = try ctx.useSignal(i32, 0);
sig.get(); // read AND subscribe the current effect
sig.peek(); // read without tracking
sig.set(42); // write + notify (no-op if value unchanged)
sig.increment() // numeric convenience (also: decrement)set skips notification when the new value equals the old (std.meta.eql), so spurious re-runs never happen.
Effects
createEffect runs a function eagerly once to collect dependencies, then re-runs it whenever any signal it read changes:
const Watcher = struct {
count: *verve.Signal(i32),
fn run(self: *@This()) void {
std.log.info("count is now {d}", .{self.count.get()});
}
};
var w = Watcher{ .count = sig };
_ = try ctx.useEffect(&w, Watcher.run);The context-pointer pattern (ctx_ptr + comptime f) is how Verve does closures without allocations: state travels in the struct, the function is comptime-known.
Owners
Every signal and effect registers with an Owner — the reactive scope. On the server each request gets an owner that is disposed when the response is sent; on the client the runtime's root owner lives for the page. Disposing an owner unsubscribes its effects from every signal they read — no leaks, no stale callbacks.
batch and untrack
// Coalesce writes — effects run once after the block, not per write:
verve.batch(&updater, Updater.run);
// Read without subscribing (logging, conditions):
const v = verve.untrack(i32, sig, readFn);Live demo
The counter below is server-rendered with the real count, then hydrated by a WASM chunk. Each click writes one signal; the on_set hook mirrors the write into the single bound <span> — nothing else re-renders.
Verve Counter
0Total clicks: 0
Server vs client
The same Signal/Effect types compile to both targets:
- Server — signals seed SSR output (
bindattributes carry the initial value into the HTML). The request owner disposes everything at end of render. - Client (WASM) — hydration walks the DOM, allocates a signal per
z-bindattribute, and wiresSignal.on_setto a DOM text setter. Island chunks register named signals viaverve.registerI32("name", 0)and mutate them withverve.signalSetI32— the runtime routes the write to the bound element.
Binding values in SSR
ctx.span().bind("count").textInt(initial)renders <span z-bind="count">0</span>. After hydration, any chunk that calls signalSetI32("count", n) updates that span — and only that span.
Next: Rendering covers the node tree those bindings live in.