Guides

Core concepts

State in Undra lives in the Rust core as signals inside stores. Platforms hold a local mirror, reads never cross the boundary, and a write crosses once per transaction.

The building blocks#

Everything a platform can see is one of eight kinds of thing, each declared with an attribute. They cross the boundary in one of two ways: by value (copied) or by handle (a reference to something that stays in the core).

ConceptDeclared withCrosses
Record#[undra::api] on a structby value
Enum#[undra::api] on an enum, unit or data variantsby value
Error#[undra::error] on an enum; adds Display and std::error::Errorby value
Object#[undra::api] on an impl block; methods are sync or asyncby handle
Storean object whose struct has #[undra::store]; its signals are mirroredby handle
Port#[undra::port] on a trait; implemented by the platform or by a Rust fakethe core calls out to it
Query, mutation#[undra::query], #[undra::mutation] on an async fnmanaged by the query client
Function#[undra::api] on a free fnlike a method with no receiver

The rest of this page is about stores, because that is where reactive state lives.

Stores#

A store is a struct of signals with an impl block that says what the platforms can call. The to-do list from the playground is a complete example:

core/src/todos.rs
#[undra::store(restore = "Self::assemble")]
pub struct Todos {
    next: AtomicU64,                       // private state: not a signal
    #[undra(key = "id")]
    todos: Signal<Vec<Todo>>,              // signal 0
    filter: Signal<Filter>,                // signal 1
    #[undra(key = "id")]
    visible: DerivedList<Todo>,            // signal 2, a view of todos
    remaining: Computed<u32>,              // signal 3, derived
}

#[undra::api(store)]
impl Todos {
    pub fn new(ctx: Ctx) -> Self { /* … */ }
    pub async fn add(&self, title: String) -> Result<Todo, TodoError> { /* … */ }
    pub fn toggle(&self, id: Uuid) { /* … */ }
    pub fn set_filter(&self, filter: Filter) { self.filter.set(filter); }
}

A few rules worth knowing up front:

Signals#

A Signal<T> holds a value. You read it with get() (a clone) or with(|v| …) (a borrow), and write it with set(value) or update(|v| …).

rust
self.filter.set(Filter::Done);                       // replace
self.todos.update(|list| list.retain(|t| !t.done));  // edit in place
let open = self.todos.with(|list| list.iter().filter(|t| !t.done).count());

A write outside a transaction is an implicit single-write transaction, so the change is committed and delivered at once. Inside update, the closure holds the value's write lock: it must not read or write the same signal, or read a computed derived from it. A violation panics with a clear message instead of deadlocking.

Computeds#

A Computed<T> is a value derived from signals. You name its inputs and give it a function of them:

rust
let parity = Computed::new(&count, |n| if n % 2 == 0 { Parity::Even } else { Parity::Odd });
let summary = Computed::new((&count, &changes), |(n, changes)| format!("{n} after {changes} changes"));

Inputs are &Signal<A> or &Computed<A>, or a tuple of up to six of them, and the closure receives references. A computed is recomputed in the core, in dependency order, and pushed in the same change-set as the write that caused it. The UI never derives anything itself, so it cannot disagree with the core about, say, how many items are left.

A computed list is recomputed and sent whole on every change of its inputs. For a filtered or sorted view of a keyed list, use a derived list, which costs the change instead.

Because a store with computeds cannot be rebuilt from its plain signals automatically, you name a function that assembles it: #[undra::store(restore = "Self::assemble")]. The macro tells you when you forget (E0013).

Transactions#

When several signals change together, group the writes. Everything inside ctx.txn commits as one transaction, so observers see one consistent change-set and never a half-applied state. The playground's counter changes two signals and derives a third:

core/src/counter.rs
pub fn add(&self, amount: i32) {
    self.ctx.txn(|| {
        self.count.update(|c| *c = c.saturating_add(amount));
        self.changes.update(|n| *n = n.saturating_add(1));
    });   // one change-set carries count, changes and parity
}

Transactions nest: an inner txn joins the outer one. A change-set is never split, and change-sets are delivered in commit order; for one store they reach the platform one at a time with strictly increasing transaction ids.

Change-sets and observation#

A platform does not receive everything. It observes signals, and only observed signals that changed are sent. Two details follow from that:

A signal marked #[undra(no_coalesce)] is delivered on every commit instead of being folded into the latest value. A change-set is a compact binary structure, described on the architecture page.

For side effects that belong in the core, Effect::new(deps, f) runs f after each commit that dirtied one of its dependencies, and is cancelled when dropped.

Keyed lists#

A Signal<Vec<T>> marked #[undra(key = "field")] is a keyed list. The key is the identity of a row, and the core sends changes to it as a patch of positional operations instead of the whole list.

OperationMeaning
Insert { index, item }put the item at index
Remove { index }take the row at index out
Update { index, item }replace the row at index
Move { from, to }take the row at from, then place it so it ends at to
Clearempty the list

The platform applies the operations in order to its own copy of the list. If a patch ever disagrees with that copy (an index out of bounds), the platform treats the signal as out of sync and observes it again, which resends the full value.

Why a list write costs the change, not the list#

How the core produces the patch decides the cost. There are two ways.

Recorded operations are the fast path. Signal<Vec<T>> has push, insert, remove, update_at, move_item and clear. Each one mutates the list and appends the operation it performed to a log; at commit, that log is the patch. No key is hashed and no item is compared, so the cost is the change, not the length of the list.

examples/playground/core/src/biglist.rs
/// Inserts a new row so that it ends at `index`. One keyed `Insert`.
pub fn insert_at(&self, index: u32, label: String) -> Result<u32, ListError> {
    // …check the index…
    let id = self.next_id.fetch_add(1, Ordering::Relaxed);
    self.items.insert(index as usize, Item { id, label, version: 0 });
    Ok(id)
}

Raw writes (set, update, replace) are the fallback. They are always correct, but the core cannot know what you did, so at commit it diffs the new list against what the platform has: hash every key, compare the surviving rows. That is O(list). It also falls back to sending the whole value when the two lists share no key, a key occurs twice, or more than half of the old rows were removed.

The difference is measured. On a 10,000-row observed list, median times from the benchmark suite (host-measured, Apple M5 Pro):

One change to 10,000 rowsDiffedRecorded
Insert536.16 µs6.31 µs
Update532.32 µs272.3 ns
Move702.95 µs9.74 µs

What remains at 10,000 rows is the memory move of the vector's tail, which Vec::insert needs in the core and which is paid twice (the list and its baseline): about 0.6 ns per row. The same insert into 100 rows is 359 ns and into 1,000 rows is 770 ns. The decision record is ADR-027.

Rule of thumb. Inside a store, reach for push, insert, remove, update_at and move_item when you change one row. Use set or update when you genuinely replace the list (a refresh from the server) or make an edit no recorded operation can express. A transaction that mixes both is diffed as a whole.

Derived lists#

A DerivedList<T> is a filtered, sorted or mapped view of a keyed list, and it costs the change, not the list. You build it from the list with a pipeline:

core/src/todos.rs
let visible = todos.derive().filter_with(&filter, |filter, todo| filter.matches(todo)).build();
let remaining = todos.derive().filter(|todo| !todo.done).count();   // a Computed<u32>

Its value is always the obvious one: the rows that pass, mapped, then sorted stably by key (filter, filter_with, map, at most one sort_by_key; equal keys keep the list's order). But it is never recomputed. It replays the list's recorded operations on an index of its own, O(log n) each, and the platform receives each as at most two patch operations: a row that enters or leaves the filter is an Insert or a Remove, a row whose sort key changes a Move and an Update (so lists animate it), a change the filter hides nothing at all. The platforms see a read-only keyed list, generated exactly like a computed list.

One row changed in 10,000Computed listDerived list
Core, per change176.5 µs333 ns
Sent to the platform352.6 KB158 bytes
Applied on the web (TypeScript)1.07 ms1.6 µs

Rule of thumb. Write the source list with the recorded operations: a raw write (set, update, replace) rebuilds every view of it and sends them whole. Keep pipeline closures pure functions of the row (debug builds panic when one reads a signal); a value the view depends on besides its rows is a parameter (filter_with, sort_by_key_with). Host-measured, Apple M5 Pro; the decision record is ADR-039.

What the platforms see#

Each platform runtime holds a mirror: store handle, then signal id, then the decoded value. Generated code exposes the mirror in the platform's own idiom (an @Observable property, a StateFlow, a Signal with useSignal), so reading is a local memory read that never calls into the core.

Every change-set is applied on the main thread, in one hop per transaction: Swift on the MainActor, Kotlin on Dispatchers.Main.immediate, and TypeScript in a queued flush on the page's main thread. A store is an object with a lifetime: release it explicitly with close() (Swift and Kotlin use deinit and a Cleaner as backstops, TypeScript a FinalizationRegistry), and UndraCore.stats() shows live handle counts.

Snapshot and restore#

The core can snapshot every store's plain signals (computeds are left out and recomputed) and restore them, which is how a web page recovers after a crash and how tests rebuild state. Restore keeps the same handles valid, so the platform's references keep working, and it cancels calls that were running on the stores it replaced. This is why the restore function above rebuilds computeds from signals, and why the to-do store continues its id counter above the largest id in the snapshot.

Determinism#

The core reads no clock and no random source and starts no thread of its own. Time comes from the Clock port, delays from ctx.sleep (which goes through the Timer port), randomness from Rng, the network from Http. That is rule R12, and it is why every scenario can be driven with deterministic fakes in Rust and with in-memory adapters on each platform. The to-do store's ids come from a counter for the same reason. See Ports & adapters.

Edit this page on GitHub