Reactivity & Signals
Ankurah’s headline behavior – UIs that update when local commits or delivered remote changes apply to their node – is built on a small set of signal-aware read surfaces. Reading a live query, View, or signal value inside an observed scope records a dependency. When a local or remote change applies, Ankurah notifies the dependents whose data changed; there is no application-level polling loop or event bus to wire up.
This page covers the model and the framework-agnostic API. For React specifics see React Bindings.
Three things you can observe
A live query’s results. ctx.query(...) returns a LiveQuery<View>.
Subscribing yields a ChangeSet describing exactly what happened:
use ankurah::signals::Subscribe;
let live: LiveQuery<AlbumView> = ctx.query("year > 2000")?;
live.wait_initialized().await;
let _guard = live.subscribe(|changes| {
println!("Received changes: {changes}");
});
Each change in the set is Initial, Add (entered the result set),
Update (already in the set, fields changed), or Remove (left the set).
A LiveQuery is also a signal itself: reading its items inside an observed
scope subscribes that scope to future changes.
A single entity. Every View implements Subscribe; the listener
fires on any field change to that entity:
let _guard = album.subscribe(|view: AlbumView| {
// any field of this entity changed
});
A single field is not a public read-side signal yet. Transaction-bound
mutable field wrappers expose backend signal machinery, but their broadcasts
belong to the transaction fork and are not a reliable observer of committed
state. To react to application data, observe the whole read-side View or a
LiveQuery instead.
Subscribe vs. observe
There are two consumption styles, and framework integrations use the second:
- Subscribe – explicit:
thing.subscribe(listener)returns a guard. You get the new value pushed to your callback. - Observe (tracking) – implicit: run code inside an observer scope,
and every signal it reads becomes a dependency. On any change, the
scope re-runs (or re-renders). This is how the React and Leptos integrations
work, and why component code contains no subscription bookkeeping at all
– reading
livequery.itemsinside an observed component is the subscription.
Rules that matter
- Keep the guard.
subscribe()returns a guard; dropping it unsubscribes. Assign it to a binding that lives as long as you want notifications (let _guard = ...– notlet _ = ..., which drops immediately). - Account for live-query initialization. A listener attached before a
LiveQueryinitializes can receive its initialChangeSet. The example above awaitswait_initialized()first, so its callback sees only later changes. Other already-initialized signals run listeners on changes, not merely because you subscribed. Observer-style consumers naturally render the current value first and then re-render on later changes. - One notification per commit. A transaction touching several fields of an entity produces a single notification per subscriber, after the commit applies – not one per field write.
- Delivery is synchronous. Listeners run inline when the change applies, on whatever thread applied it. Keep listeners cheap; hand off heavy work.
Signals for your own state
The same primitives that power entity reactivity are exported for application state, so derived values can mix Ankurah data with local state in one dependency graph:
| Type | Semantics |
|---|---|
Mut<T> | Read/write cell; set() notifies dependents |
Read<T> | Read-only handle sharing a Mut’s cell |
Map | Transforms an upstream signal on every read (no cache) |
Memo | Like Map, but caches until the upstream changes |
Calculated<T> | Runs a closure, auto-tracks every signal it reads, recomputes on change |
Framework wiring
React – the @ankurah/react-hooks package exports a factory:
import React from "react";
import { createAnkurahReactHooks } from "@ankurah/react-hooks";
import { ReactObserver } from "your-wasm-bindings";
const { signalObserver } = createAnkurahReactHooks({ React, ReactObserver });
Wrap components in signalObserver(...); it brackets the render’s tracking
lifecycle, and any signal read during that render – a LiveQuery’s items, a
View’s fields – subscribes the component via React’s useSyncExternalStore.
There is deliberately no use_query hook: queries are plain
LiveQuery objects you create and read; observation is the only
React-specific part. Full usage: React Bindings.
The factory only wires observation. Your application must initialize its
WASM module and Ankurah client before rendering code that calls ctx(); the
React setup shows the maintained template’s startup order.
Leptos – support is built into ankurah-signals via the
reactive-graph feature (enabled by default): install the bridge once at
startup,
// Install the ReactiveGraphObserver at the base of the Ankurah observer stack
// so that Leptos components can observe Ankurah signals via reactive_graph.
use ankurah_signals::{CurrentObserver, ReactiveGraphObserver};
CurrentObserver::set(ReactiveGraphObserver::new());
then read Ankurah signals (livequery.get(), view fields) inside Leptos
closures like any other reactive value – dependencies register with
Leptos’s reactive graph automatically. The
Leptos template
shows the wiring end to end. The bridge works, but remains the younger,
experimental frontend path; expect rough edges and use the maintained React
path when you need the more established integration.
How it connects underneath
Per node, a reactor matches every applied entity change against active
query subscriptions, updates each query’s result set, and emits the
ChangeSets described above; entity and field broadcasts fire from the
same applied-change notification path. The machinery – and the ordering guarantees behind
“one notification per commit” – is contributor territory: see
Entity Lifecycle and
Conflict Resolution & Guarantees.