Signals & reactivity
Signal, Computed, AsyncSignal, PersistentSignal — the reactive contract.
The keystone primitive is Signal<T> — a value plus a listener list. Everything reactive flows from it.
Signal
Signal<int> count{0};
count.listen([](int v){ std::cout << v; });
count.set(42); // listeners fire
count.update([](int v){ return v + 1; }); // listeners fire
int v = count.get(); // no listener side effects
In .vel source, @state declarations with a plain initializer become Signal<T>:
#Counter
@count = 0 // Signal<int>
The component constructor listens on count_ and marks itself dirty when it changes.
Computed
@doubled = $count * 2
velc detects that the right-hand side references another signal and lowers this to:
Computed<int> doubled_{ [this]{ return count_.get() * 2; } };
Computed<T> walks the closure’s dependency list once at construction, subscribes to each, and re-evaluates lazily on .get() after invalidation.
AsyncSignal
@users: std::vector<User> = await fetchUsers()
Becomes:
AsyncSignal<std::vector<User>> users_{
std::async(std::launch::async, []{ return fetchUsers(); })
};
AsyncSignal<T> polls the future on every tick() and exposes four observable fields:
| Field | Type | Meaning |
|---|---|---|
.loading | bool | Future is still pending. |
.ready | bool | Future resolved successfully. |
.value | T | The resolved value (only valid when .ready). |
.error | std::string | The exception message, if any. |
In .vel, you read those as $users.loading, $users.value, etc. Each one is itself a reactive cell — branches re-evaluate on transition.
PersistentSignal
@theme = persist("theme", "dark")
Becomes a PersistentSignal<std::string> that auto-syncs to a JSON-backed key-value store (see framework::Storage). The value is hydrated on construction; every set() writes through on the next idle moment.
Effects
~ syntax declares an effect:
~ $count =>
notify::toast("count changed to {$count}")
The codegen walks the body, finds every $signal reference, and registers a listener on each. Cleanup is automatic when the component unmounts.
Frame-level damage tracking
A global atomic frameDirty flag, raised by any Widget::markDirty() call, controls whether the next frame runs at all. Reactive rebuilds set it. Animations re-arm it from their tick(). Static pages don’t, so the app sits in glfwWaitEventsTimeout at ~0 CPU until something actually changes.
This is not fine-grained reactivity — granularity is per-component, not per-DOM-node. The rebuild walks the component’s build() and reconciles against the previous widget tree. It’s coarse like React, not surgical like Solid. The trade-off is one cheap pass per dirty component instead of an observer graph touching every primitive.