Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
Reactive Core

The reactive core is Aria's foundation. Every observable value, every derived computation, every side effect flows through a single process-wide DAG (directed acyclic graph). This chapter covers the five primitives you'll use every day:

  • Property<T> — observable source node
  • Computed<T> — auto-tracked derived value
  • Effect — auto-tracked side-effect reaction
  • Subscription — RAII handle for any observation
  • batch() / untracked() — transaction and escape-hatch utilities

Include: #include "aria/aria.hpp" (umbrella) or #include "aria/reactive/reactive.hpp"


Property<T>

Property<T> is the source of truth. It owns a value, notifies on change, and auto-tracks reads inside Computed / Effect.

Basic Usage

#include "aria/aria.hpp"
aria::Property<bool> active{true};
Definition property.hpp:103

Read

// Auto-tracked: inside Computed/Effect, registers as dependency
std::string n = name.get(); // returns copy
const std::string& nr = name.get_ref(); // by ref, avoids copy
// Non-tracking: snapshot, never registers dependency
std::string snap = name.peek();
const std::string& snapr = name.peek_ref();
// Implicit conversion (same as get())
std::string s = name;
T peek() const noexcept(std::is_nothrow_copy_constructible_v< T >)
Snapshot read that does NOT register a dependency.
Definition property.hpp:164
const T & peek_ref() const noexcept
Snapshot read by const reference (never tracks, never copies).
Definition property.hpp:169
T get() const
Auto-tracked read.
Definition property.hpp:138
const T & get_ref() const
Auto-tracked read by const reference.
Definition property.hpp:150

Write

name.set("Bob"); // equality-gated: no-op if already "Bob"
name = "Charlie"; // operator= calls set()
// Mutate-in-place: always fires (cannot detect no-op)
items.mutate([](std::vector<int>& v) { v.push_back(42); });
void set(const T &new_val)
Commit a new value.
Definition property.hpp:179
void mutate(Fn &&fn)
Mutate-in-place: always fires a change (cannot detect no-op because the mutation is opaque).
Definition property.hpp:189

Equality gate: set(v) where v == current is a no-op. Downstream observers are not notified. This eliminates redundant work automatically.

Observe

// on_changed: fires on every change (not on initial bind)
auto sub = name.on_changed([](const std::string& val) {
std::cout << "name is now: " << val << "\n";
});
// bind: fires immediately with current value, then on changes
auto sub2 = age.bind([](int val) {
std::cout << "age: " << val << "\n"; // fires NOW with 30
});
// observe: receives (old_value, new_value)
auto sub3 = age.observe([](const int& old_val, const int& new_val) {
std::cout << "age: " << old_val << " -> " << new_val << "\n";
});
::aria::Subscription observe(std::function< void(const T &, const T &)> fn)
Two-argument form: receive (old, new).
Definition property.hpp:220
::aria::Subscription bind(std::function< void(const T &)> fn)
Fire once with the current value, then on every subsequent change.
Definition property.hpp:210
::aria::Subscription on_changed(std::function< void(const T &)> fn)
Run fn(new_value) every time the value changes.
Definition property.hpp:200

All three return a Subscription. Drop it (or call .release()) to stop receiving callbacks.

Constraints

  • T must be copyable and equality-comparable (operator==)
  • Property is non-copyable, non-movable — its identity in the graph is tied to its address
  • Single-threaded: all reads/writes must happen on the graph thread (debug builds assert)

Computed<T>

Computed<T> derives its value from other reactive nodes. Dependencies are discovered automatically — every Property::get() or Computed::get() called inside the compute function becomes an upstream.

Basic Usage

aria::Property<double> subtotal{100.0};
aria::Property<double> tax_rate{0.08};
return subtotal.get() * (1.0 + tax_rate.get());
}};
// total.get() == 108.0 right now
subtotal = 200.0;
// total.get() == 216.0 — recomputed automatically
Definition computed.hpp:86

Conditional Dependencies

Dependencies shift dynamically based on which branches execute:

aria::Property<bool> use_celsius{true};
aria::Property<double> celsius{25.0};
aria::Property<double> fahrenheit{77.0};
aria::Computed<double> display_temp{[&] {
return use_celsius.get() ? celsius.get() : fahrenheit.get();
}};
// Currently depends on: use_celsius, celsius
// If use_celsius = false, next recompute depends on: use_celsius, fahrenheit

Lazy & Memoized

  • get() returns the cached value — no recomputation unless an upstream changed
  • Recomputation only happens during a graph flush, in topological order
  • If the recomputed value equals the previous one, downstream propagation stops (glitch-free)

Observe

Same API as Property:

auto sub = total.on_changed([](double val) { /* ... */ });
auto sub2 = total.bind([](double val) { /* ... */ });
auto sub3 = total.observe([](double old_v, double new_v) { /* ... */ });
::aria::Subscription on_changed(std::function< void(const T &)> fn)
Definition effect.hpp:160
::aria::Subscription observe(std::function< void(const T &, const T &)> fn)
Definition effect.hpp:190
::aria::Subscription bind(std::function< void(const T &)> fn)
Definition effect.hpp:179

Effect

Effect runs a side-effect function every time any tracked read changes. Equivalent to MobX autorun, SolidJS createEffect, or Svelte 5 $effect.

Basic Usage

aria::Effect logger{[&] {
std::cout << "count = " << count.get() << "\n";
}};
// Prints "count = 0" immediately (eager first run)
count = 1; // Prints "count = 1"
count = 2; // Prints "count = 2"
Definition effect.hpp:127

Stop and Restart

aria::Effect eff{[&] { /* ... */ }};
eff.stop(); // Disconnect from graph
// ... changes to upstreams are ignored ...
// Cannot restart — create a new Effect instead
void stop() noexcept
Explicitly cancel (without waiting for destruction).
Definition effect.hpp:141

Convert to Subscription

Effects can be transferred into a SubscriptionBag:

aria::Effect eff{[&] { /* ... */ }};
bag += std::move(eff).into_subscription();
// bag.release() or destruction disconnects the effect
Aggregate holder: owns multiple Subscriptions and drops them together.
Definition subscription.hpp:116

Subscription

Unified RAII handle for any observation — reactive graph, signals, event bus, all in one type.

aria::Subscription sub = name.on_changed([](const std::string&) {});
sub.active(); // true
sub.release(); // disconnect now
sub.active(); // false
RAII handle to a single subscription.
Definition subscription.hpp:44
bool active() const noexcept
Definition subscription.hpp:85
void release() noexcept
Explicitly disconnect now (instead of at destruction).
Definition subscription.hpp:83

SubscriptionBag

Aggregate holder — drop all subscriptions at once:

bag += name.on_changed([](const std::string&) {});
bag += age.on_changed([](int) {});
bag += total.on_changed([](double) {});
bag.clear(); // Disconnects ALL at once
bag.size(); // 0
std::size_t size() const noexcept
Definition subscription.hpp:154
void clear() noexcept
Disconnect the current contents in reverse insertion order.
Definition subscription.hpp:148

In a ViewModel, track() does the same thing via the internal bag.


batch()

Coalesce multiple writes into a single flush. Observers see one notification, not N:

aria::Computed<int> sum{[&] { return x.get() + y.get(); }};
// WITHOUT batch: sum recomputes twice (glitch!)
x = 10; // sum recompute: 10 + 2 = 12
y = 20; // sum recompute: 10 + 20 = 30
// WITH batch: sum recomputes once
x = 10;
y = 20;
});
// sum recompute: 10 + 20 = 30 — one flush, no intermediate state
auto batch(Fn &&fn) -> decltype(fn())
Sugar: batch([&]{ firstName = "..."; lastName = "..."; }).
Definition graph.hpp:369

BatchScope (RAII)

{
x = 10;
y = 20;
}
// Flush happens at scope exit
RAII batch guard: { BatchScope b; ...; } or use batch([&]{...}).
Definition graph.hpp:346

untracked()

Opt out of auto-tracking inside a Computed or Effect:

// a is tracked, b is NOT
int val = a.get();
int snap = aria::untracked([&] { return b.get(); });
return val + snap;
}};
// Changing b does NOT trigger recompute
// Changing a DOES trigger recompute
auto untracked(Fn &&fn) -> decltype(fn())
Sugar: untracked([&]{ ... }).
Definition graph.hpp:329

UntrackedScope (RAII)

int val = a.get();
int snap;
{
snap = b.get();
}
return val + snap;
}};
RAII: within the scope, every dep() behaves like a plain get().
Definition graph.hpp:318

dep()

Explicit dependency hint — "treat `x` as an upstream even if I didn't read it":

aria::Property<int> click_count{0};
aria::Effect eff{[&] {
aria::dep(click_count); // Depend on it without reading
std::cout << "something happened\n";
}};
requires ::aria::ReactiveNode< Reactive > void dep(Reactive &r)
Definition computed.hpp:76

Rarely needed — get() auto-tracks in 99% of cases.


CircularDependencyError

If the graph detects a cycle (A → B → A), it throws aria::CircularDependencyError instead of spinning infinitely. This is a hard error, not a warning — fix the cycle.


Quick Reference Table

Primitive Kind Produces Value Auto-Tracked Eager First Run
Property<T> Source Yes N/A (is source) N/A
Computed<T> Derivation Yes Yes Yes
Effect Reaction No Yes Yes
batch() Transaction
untracked() Escape hatch
dep() Hint

See Also