Signals & Reactivity¶
maya includes a fine-grained reactive signal system inspired by SolidJS and Leptos. Signals hold mutable values. Computed nodes derive values lazily. Effects run side-effects. All dependency tracking is automatic.
Signal\<T> — Reactive Mutable Value¶
A Signal holds a value and notifies subscribers when it changes:
Signal<int> count{0};
count.get(); // Read: returns 0
count(); // Shorthand for get()
count.set(42); // Write: notifies all dependents
count.update([](int& n) { n += 10; }); // Mutate in-place
Construction¶
Signal<int> a{0}; // int, starts at 0
Signal<std::string> name{"alice"}; // string
Signal<float> ratio{0.5f}; // float
Signal<std::vector<int>> nums{{1, 2, 3}}; // vector
Reading — get() and operator()¶
Automatic tracking: If get() is called inside a Computed or Effect
scope, the signal is automatically registered as a dependency. When the signal
changes, the computed/effect re-evaluates.
Writing — set()¶
If the new value equals the old value (via operator==), no notification is
sent. This prevents unnecessary re-renders.
Mutating — update()¶
update() always notifies, since it can't know if the mutation changed the
value. Use set() when possible for the equality check.
Derived Values — map()¶
Signal<int> width{10};
auto doubled = width.map([](int w) { return w * 2; });
// doubled is a Computed<int> that tracks width
Version Tracking¶
Computed\<T> — Lazy Derived Value¶
A Computed is a read-only reactive value derived from other signals. It's
memoized — only recomputes when at least one dependency has changed:
Signal<int> width{10};
Signal<int> height{20};
auto area = computed([&] {
return width.get() * height.get();
});
area.get(); // Returns 200
area(); // Same thing
width.set(15);
area.get(); // Returns 300 (recomputed because width changed)
area.get(); // Returns 300 (cached — nothing changed)
How It Works¶
- On creation, the compute function runs once to establish initial
dependencies (via
get()calls inside the lambda). - When any dependency changes, the computed node is marked dirty.
- On the next
get(), it re-evaluates, re-establishes dependencies, and caches the new value. - If the new value equals the old (via
operator==), downstream subscribers are not notified — the change stops propagating.
Chaining¶
Computed values can depend on other computed values:
Signal<float> price{10.0f};
Signal<int> quantity{3};
auto subtotal = computed([&] { return price() * quantity(); });
auto tax = computed([&] { return subtotal() * 0.08f; });
auto total = computed([&] { return subtotal() + tax(); });
total.get(); // 32.4
price.set(20.0f);
total.get(); // 64.8 (subtotal, tax, and total all recomputed)
Effect — Reactive Side-Effects¶
An Effect runs a function whenever its dependencies change. It's an RAII
object — alive as long as it exists, disposed when destroyed:
Signal<int> count{0};
auto fx = effect([&] {
std::println("count is now {}", count.get());
});
// Prints: "count is now 0" (runs immediately on creation)
count.set(5);
// Prints: "count is now 5"
count.set(5);
// Nothing printed (value didn't change, no notification)
count.set(10);
// Prints: "count is now 10"
Lifecycle¶
{
auto fx = effect([&] { /* ... */ });
// Effect is active
count.set(1); // Effect runs
}
// fx destroyed — effect is disposed, no longer runs
count.set(2); // Effect does NOT run
Manual Disposal¶
auto fx = effect([&] { /* ... */ });
fx.active(); // true
fx.dispose(); // Unsubscribes from all dependencies
fx.active(); // false
Use Cases¶
- Logging state changes
- Triggering side-effects (network calls, file writes)
- Synchronizing external state with signal values
- Calling
maya::quit()when a condition is met
Batch — Coalescing Updates¶
When setting multiple signals, each set() triggers notifications immediately.
Batch defers all notifications until the batch scope ends:
Signal<int> x{0}, y{0};
auto fx = effect([&] {
std::println("x={}, y={}", x(), y());
});
// Prints: "x=0, y=0"
// Without batch:
x.set(10); // Effect runs: "x=10, y=0"
y.set(20); // Effect runs: "x=10, y=20" (two evaluations!)
// With batch:
{
Batch batch;
x.set(10); // Deferred
y.set(20); // Deferred
}
// Effect runs ONCE: "x=10, y=20"
Functional Form¶
Nested Batches¶
Batches nest correctly — only the outermost batch triggers notifications:
{
Batch outer;
x.set(1);
{
Batch inner;
y.set(2);
} // inner batch ends, but outer is still active — no notification yet
} // outer batch ends — notifications fire now
Signals in maya Applications¶
Signals are most commonly used in run(), live(), and canvas_run() for
local reactive state. In Program apps (run<P>()), the Model is the primary
state — signals can still be useful for derived/cached computations.
Pattern: Signal-Driven State (run/live/canvas_run)¶
Simple run() pairs naturally with signals — closures capture signal refs
directly and dyn() ensures only the reactive parts re-render:
Signal<int> count{0};
run({.fps = 30},
[&](const Event& ev) {
on(ev, '+', [&] { count.update([](int& n) { ++n; }); });
return !key(ev, 'q');
},
[&] {
return (v(
dyn([&] { return text(count.get()) | Bold; }),
t<"[+] count [q] quit"> | Dim
) | pad<1>).build();
}
);
Signal<int> count{0};
Signal<std::string> message{"Ready"};
live({.fps = 30}, [&] {
return (v(
dyn([&] { return text(message.get()); }),
dyn([&] { return text(count.get()) | Bold; })
) | pad<1>).build();
});
Pattern: Program with Plain Model (preferred)¶
In Program apps, use plain data in the Model instead of signals:
struct MyApp {
struct Model { int count = 0; std::string message = "Ready"; };
struct Inc {};
using Msg = std::variant<Inc>;
static auto update(Model m, Msg) -> std::pair<Model, Cmd<Msg>> {
m.count++;
m.message = "Count: " + std::to_string(m.count);
return {m, {}};
}
static Element view(const Model& m) {
return v(text(m.message), text(m.count) | Bold) | pad<1>;
}
// ...
};
Pattern: Computed Display Values¶
Signal<float> cpu{0.0f};
Signal<float> mem{0.0f};
auto health = computed([&] {
float c = cpu(), m = mem();
if (c > 90 || m > 90) return "CRITICAL";
if (c > 70 || m > 70) return "WARNING";
return "OK";
});
Pattern: Batch Updates from External Data¶
void on_metrics(const Metrics& m) {
batch([&] {
cpu.set(m.cpu);
mem.set(m.mem);
disk.set(m.disk);
net.set(m.net);
});
// UI re-renders once with all four values updated
}
Thread-Safety Model¶
Signals are thread-local. Each thread has its own reactive graph with:
- Its own scope stack (current_scope)
- Its own batch depth counter
- Its own pending notification queue
Do not share Signal objects across threads. For cross-thread communication,
use channels, atomics, or message passing — then update signals on the
receiving thread.
Reactive Graph Internals¶
The reactive system uses a simple dependency graph:
- Signals are pure sources — they have subscribers but no dependencies.
- Computed nodes are both — they subscribe to sources and have their own subscribers.
- Effects are pure sinks — they have dependencies but no subscribers.
When a signal changes:
1. notify_subscribers() walks the subscriber list.
2. Each subscriber is mark_dirty() + evaluate().
3. Computed::evaluate() re-runs the compute function, re-establishes
dependencies via track(), and propagates if the value changed.
4. Effect::evaluate() re-runs the effect function.
This is a push-then-pull model: changes push "dirty" flags down the graph, then evaluation pulls fresh values as needed.