Reactivity
Keep facts in Signals, derive with Memos, and sync the outside world with Effects.
Three primitives
A writable source of truth. Read with get(), write with set(v); reading records a dependency in the current computation.
Computed lazily from other reactive values; reuses its last result while inputs are unchanged and stays quiet when the value compares equal.
Runs a side effect when its dependencies change: updating a node, logging, syncing an external system.
End-to-end data flow
ui.textFmt subscribes to the Signals / Memos you pass and re-formats just that one text node when they change. A Memo receives its dependencies through an explicit context struct.
const count = try scope.createSignal(u32, 0);
const doubled = try scope.createMemo(u32, .{ .count = count }, struct {
fn compute(ctx: anytype) u32 {
return ctx.count.get() * 2;
}
}.compute);
try root.appendChild(cx.allocator, try ui.textFmt(
cx,
scope,
"count = {d}, doubled = {d}",
.{ count, doubled },
.{ .color = cx.tokens.color.fg_primary },
));Signals, not diffing
zenit keeps a real UI tree for layout, hit-testing and drawing, but updates don’t go “re-run components, build a candidate tree, diff the whole thing”. Memos and Effects that read a Signal register a dependency; set notifies exactly those subscribers, which mark the affected nodes layout- or render-dirty.
Updating from events
Component callbacks are HandlerRefs. Keep a small struct holding the Signal pointer with cx.bindState, then turn one of its methods into a callback with cx.on.
const Bindings = struct {
count: *ui.Signal(u32),
fn increment(self: *Bindings) void {
self.count.set(self.count.get() + 1);
}
};
const bindings = try cx.bindState(Bindings, .{ .count = count });
const on_click = cx.on(Bindings, bindings, Bindings.increment);
const plus = try ui.widgets.Button(.{
.label = "+1",
.on_click = on_click,
}).mount(scope, cx);Reading and subscribing
signal.get()Read and register a dependency in the current Memo / Effectsignal.peek()Read without subscribing. Handy for snapshots inside handlerssignal.set(v)Write and notify subscriberssignal.update(fn)Compute the next value from the previous one, then writescope.createEffect(ctx, fn)Run a side effect on change; disposed with its Scope// Effect: sync a value to something outside the reactive graph.
try scope.createEffect(.{ .count = count }, struct {
fn run(ctx: anytype) void {
std.log.info("count is now {d}", .{ctx.count.get()});
}
}.run);
// peek(): read without subscribing (no dependency is recorded).
const snapshot = count.peek();
// update(): read-modify-write in one call.
count.update(struct {
fn inc(v: u32) u32 {
return v + 1;
}
}.inc);Rules of thumb
- ✓
One fact, one Signal. Don’t keep the same state in both a plain field and a Signal.
- ✓
Derivable values are Memos. Don’t hand-sync derived fields in every handler.
- ✓
Effects only sync. Never unconditionally write back to an Effect’s own dependency. That’s a loop.
- ✓
Lifetimes belong to a Scope. When a page’s Scope is disposed, its Signals, Memos and Effects go with it.