zgui
Architecture

Reactive internals

The arena the reactive graph lives in, how a subscription is recorded, what the flush does, and how cross-thread work requests a frame.

This page is the inside of zgui-reactive: where the graph is stored, what a write actually touches, what the flush polls, and the edge that turns cross-thread work into a redraw request. It assumes the guide and the frame.

The facade and the engine

zgui does not implement a reactive engine. It pins one and wraps it.

LayerCrateWhat it is
enginereactive_graph =0.2.14, features ["effects"]The graph, the arena, the ownership tree
enginereactive_stores =0.4.3Per-field reactivity over a struct
facadezgui-reactiveThe whole reactive surface of the framework
viewzgui-viewRe-exports a subset through its prelude
umbrellazguipub use zgui_reactive as reactive;

zgui-reactive is the only crate in the workspace permitted to name reactive_graph, reactive_stores, any_spawner or send_wrapper. The rule is written in crates/zgui-reactive/Cargo.toml and the versions are pinned in the workspace manifest. One crate names the engine, so the engine's version, its feature set and the handful of its APIs that are wrong for a user interface are decided once.

Two engine features matter by their absence. nightly is off, so a signal is read as count.get() and never as count(); turning it on deletes the conversions that let a component property accept a closure, and crates/zgui-reactive/src/canary.rs has a test that fails first when that happens. sandboxed-arenas is off, which is why the arena below is one map for the process rather than one per thread.

What a signal handle is

RwSignal<T, S>

A Copy slotmap key, plus a Location in debug builds.

ArenaItem<ArcRwSignal<T>, S>
SlotMap<NodeId, Box<dyn Any + Send + Sync>>

One process-wide map resolves the NodeId.

ArcRwSignal<T>
  • value: Arc<RwLock<T>> — the value
  • inner: Arc<RwLock<SubscriberSet>> — who reads it
What an RwSignal handle resolves to

The arena, in full:

// reactive_graph/src/owner/arena.rs
new_key_type! { pub struct NodeId; }
pub type ArenaMap = SlotMap<NodeId, Box<dyn Any + Send + Sync>>;
static MAP: OnceLock<RwLock<ArenaMap>> = OnceLock::new();

A slotmap key carries an index and a generation counter. A key whose slot was freed and then reused does not resolve, so ArenaItem::is_disposed is exactly !arena.contains_key(self.node), and a handle to a disposed value fails a lookup rather than reading somebody else's value. Every arena-backed read takes a read lock on that one map; every creation and every disposal takes a write lock.

That is what makes the handles Copy. RwSignal<T> is a key, not a pointer, so it moves into as many closures as a view needs without a clone and without a lifetime.

HandleStorageFreed when
RwSignal, ReadSignal, WriteSignal, Memo, Signal, Trigger, StoredValue, Callback, Store, Fieldarenaits owner is disposed of
ArcRwSignal, ArcReadSignal, ArcWriteSignal, ArcMemo, ArcSignal, ArcTrigger, ArcFieldreference countedthe last handle is dropped

The Arc forms skip the arena entirely. That is not only a lifetime difference: it is up to four fewer lock acquisitions per read, as the cost table at the end shows.

Node identity inside the graph

The graph itself does not use NodeId. It uses two type-erased handles:

// reactive_graph/src/graph/source.rs, subscriber.rs
pub struct AnySource(pub(crate) usize, pub(crate) Weak<dyn Source + Send + Sync>, /* Location */);
pub struct AnySubscriber(pub usize, pub Weak<dyn Subscriber + Send + Sync>);

The usize is the address of the reference-counted inner state, and it is what Hash and Eq use. Both directions are weak. Every graph operation is therefore an upgrade that may fail, and a node that has gone away leaves dead edges rather than dangling ones — nothing has to walk the graph to remove them.

Subscription

A tracking context is a run of some closure the reactive system performed itself: an effect body, a memo body, a reactive hole in a view. While that run is in progress, one thread-local names it.

// reactive_graph/src/graph/subscriber.rs
thread_local! {
    static OBSERVER: RefCell<Option<ObserverState>> = const { RefCell::new(None) };
}
struct ObserverState { subscriber: AnySubscriber, untracked: bool }

AnySubscriber::with_observer replaces that slot for the length of a call and restores it on drop, including on unwind. Nesting is therefore free and correct.

A read records the dependency:

// reactive_graph/src/traits.rs
impl<T: Source + ToAnySource + DefinedAt> Track for T {
    fn track(&self) {
        if self.is_disposed() { return; }
        if let Some(subscriber) = Observer::get() {
            subscriber.add_source(self.to_any_source());   // forward edge
            self.add_subscriber(subscriber);               // back edge
        } else {
            // debug builds only: the "outside a reactive tracking context" diagnostic
        }
    }
}

Two edges, one in each direction, and both are needed. The back edge is what a write walks to mark. The forward edge is what the flush walks to decide whether a marked observer really has to run, and what the next run walks to unsubscribe.

Read::try_read reduces to track() followed by try_read_untracked(), and Get, With and Read all blanket down to it. So get() is one subscription and one read, and get_untracked() is the read alone. There is no third path.

When no observer is current, debug builds print a diagnostic to standard error naming the read site and the site the signal was defined at. It is an eprintln!, it is absent from release builds, and it is easy to lose in other output — which is why the guide treats a read outside a tracking context as a silent mistake. enter_non_reactive_zone suppresses that message and nothing else: SpecialNonReactiveZone::is_inside is read only in the branch above where there is no observer, so a read inside a zone with an observer current still subscribes. To not subscribe, use an _untracked method.

The two sets

// reactive_graph/src/graph/sets.rs
type FxIndexSet<T> = IndexSet<T, BuildHasherDefault<FxHasher>>;
pub struct SourceSet(FxIndexSet<AnySource>);       // what one subscriber reads
pub struct SubscriberSet(FxIndexSet<AnySubscriber>); // who reads one source

Both preserve insertion order. SubscriberSet::unsubscribe uses shift_remove rather than swap_remove, and the engine says why: nested effects are created in order, and an inner effect may assume the outer one has already run this pass. Reordering the set breaks that.

Replacing the dependency set

An effect's task body is the whole update protocol:

// reactive_graph/src/effect/render_effect.rs
while rx.next().await.is_some() {
    if !owner.paused()
        && subscriber.with_observer(|| subscriber.update_if_necessary())
    {
        subscriber.clear_sources(&subscriber);
        let old_value = mem::take(&mut *value.write().or_poisoned());
        let new_value = owner.with_cleanup(|| {
            subscriber.with_observer(|| fun(old_value))
        });
        *value.write().or_poisoned() = Some(new_value);
    }
}

clear_sources takes the whole SourceSet and calls remove_subscriber on every source in it. The run that follows re-subscribes through track. The dependency set is rebuilt, not diffed: the cost of one run is one removal per old dependency plus one insertion per new one. A closure that read three signals on one run and one on the next ends holding exactly one.

owner.with_cleanup is cleanup() and then with(). The effect disposes of its own owner before every re-run, which is why a cleanup registered inside an effect runs before that effect's next run, and why a signal created inside an effect body is freed at the start of the next one.

Marking versus running

A write does not run anything. It moves flags, and it puts one bit into a notification.

// reactive_graph/src/graph/node.rs
pub enum ReactiveNodeState { Clean, Check, Dirty }
StateMeaningSet by
CleanNo source changed, or one did and this value did notthe node itself, after it recomputes
CheckA source may have changed; this node does not know yetmark_check from a source that was itself marked
DirtyA source definitely changedmark_dirty from a source's own write

The propagation has two speeds:

The write. set takes a write guard; dropping the guard calls Notify::notify, which is mark_dirty. For a signal that is mark_subscribers_check: clone the SubscriberSet and call mark_dirty on every direct subscriber. A signal holds no state of its own — it has no computed value that could be stale.

One edge of Dirty. A memo marked dirty sets its own state to Dirty and then calls mark_check on its subscribers.

The rest of the way as Check. mark_check sets a node to Check unless it is already Dirty, and propagates mark_check onward. So Dirty travels exactly one edge and Check travels the remainder of the observer sub-graph.

The task. At the end of every chain is an effect. EffectInner::mark_dirty sets dirty = true and notifies its channel; mark_check only notifies. Notifying is where a write stops.

The ready set

There is no list of dirty nodes for the flush to scan. The ready set is the task pool's own queue, fed by one single-slot channel per effect:

// reactive_graph/src/channel.rs
struct Inner { waker: AtomicWaker, set: AtomicBool }

impl Sender {
    pub fn notify(&mut self) { self.0.set.store(true, Relaxed); self.0.waker.wake(); }
}

The slot is one bit. Receiver::poll_next swaps it back to false when the task runs. A signal written a thousand times between two flushes therefore costs one re-run, not a thousand, and no batching API is needed to get that.

Waking pushes the task into the thread's pool:

// futures::executor::LocalPool, held in crates/zgui-reactive/src/executor/pool.rs
pool: FuturesUnordered<LocalFutureObj<'static, ()>>,
incoming: Rc<RefCell<Vec<LocalFutureObj<'static, ()>>>>,

FuturesUnordered keeps an intrusive queue of the tasks whose wakers have fired. That queue is the ready set, and flush polls it and nothing else.

Deciding at the poll rather than at the write

The task's first act is update_if_necessary(). For an effect: if dirty, clear it and answer true; otherwise walk the SourceSet and ask each source the same question. A memo answers by recomputing when its own state is Dirty, or when one of its own sources answers true while it is Check — and it returns whether the new value differs from the old one.

So a memo whose recomputed value compares equal answers false, and an effect that was only Check never runs its closure. Memo::new requires T: PartialEq; Memo::new_with_compare takes the comparison as a function pointer instead.

Marking is pushed eagerly and cheaply; the decision to run is pulled at the poll. That split is why a write inside a listener costs a few flag stores rather than a cascade of work.

The flush

// crates/zgui-reactive/src/executor/frame.rs
pub fn flush() -> FlushOutcome;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FlushOutcome {
    pub needs_another_frame: bool,
    pub budget_exhausted: bool,
}

In order:

assert_ui_thread("flush") — a debug-build panic, compiled away in release.

Claim the flush. FlushState.running is a thread-local flag. If it is already set, the call returns FlushOutcome::default() and does nothing else. A flush from inside an effect is ignored, not nested: the pool cannot be borrowed twice, and a nested executor is a panic one layer down.

Increment generation and clear both outcome flags. The generation is what the iteration budget is charged against.

Drain the closures queued through Ui::post. The queue is removed before a closure runs. A closure that posts another closure therefore schedules it for the next flush.

Enter the configured PollContext, then call pool::poll(), which is LocalPool::run_until_stalled(). The Tokio integration uses this context to enter its runtime for the complete poll. A Running drop guard clears the re-entrancy flag if a task panics.

Wake the tasks the budget set aside, and set needs_another_frame if there were any. Waking them re-queues them in the pool without asking the platform for anything.

Return the outcome.

A task that panics propagates the panic to the caller of flush and leaves the executor usable: the next flush polls what is left.

The iteration budget

Polling "until nothing is ready" does not terminate when two effects write each other's sources. Each write makes the other ready again, inside a single flush, with no frame presented and nothing logged.

// crates/zgui-reactive/src/executor/budget.rs
pub(crate) const BUDGET: u32 = if cfg!(debug_assertions) { 32 } else { 8 };

The cap is per task, per flush. It is charged in WakeThrough::poll (crates/zgui-reactive/src/executor/through.rs), which asks TaskBudget::admit(frame::generation()) before touching the inner future. A generation that does not match resets the count, so the budget is fresh every flush.

AdmissionWhenWhat happens
Runpolls so far are within the cappoll the inner future
Deferfirst poll over the cap this flushreport once at error level, hand the waker to frame::defer, return Pending
AlreadyDeferredlater polls over the cap this flushreturn Pending silently

The report names the file and line the task was spawned at when the task went through this crate's own spawn or spawn_local; effects spawned by the engine have no location and get the generic message. Debug builds allow more polls than release builds on purpose: a chatty dependency chain should be diagnosed rather than truncated while it is being investigated, and a shipped frame still has to present.

An ordinary task is polled once or twice per flush, so correct code never approaches the cap.

What the outcome reports, and who reads it

FieldTrue whenRead by
needs_another_framea wake was raised while this flush was running, or the budget set a task asidecrates/zgui-runtime/src/window/frame.rs:285, combined with three other reasons into one redraw request; and crates/zgui-runtime/src/window/input.rs:70
budget_exhaustedat least one task exceeded the capnothing in the runtime; it is the programmatic form of the logged error

An application calls neither install nor flush. App::into_handler installs (crates/zgui-runtime/src/app.rs), and the flush runs from four places: once per frame in window/frame.rs, between input events in window/input.rs, after an observation delivery in window/observe.rs, and inside the scroll animation in window/scroll/mod.rs.

The wake edge

The task pool's own waker unparks the thread it lives on. The UI thread instead waits in the platform event loop. Without a second wake edge, a task that becomes ready on another thread does not cause a frame.

So every spawned task is polled through a composite waker:

// crates/zgui-reactive/src/executor/through.rs
impl ArcWake for Composite {
    fn wake_by_ref(arc_self: &Arc<Self>) {
        arc_self.pool.wake_by_ref();
        if !frame::note_wake() {
            arc_self.target.ping();
        }
    }
}

frame::note_wake answers whether a flush is running. If one is, it records needs_another_frame and the ping is suppressed: an effect writing a signal must not ask the platform for a redraw from inside the frame that is already running, once per write.

The whole path:

Worker thread · background future completes
The task's one-shot receiver

Mark the task ready and wake it.

Composite::wake_by_ref
  • pool.wake_by_ref() — queue the task on the UI thread's pool
  • target.ping() — only when no flush is running
RuntimeWaker::wakecrates/zgui-runtime/src/wake.rs
  • If a frame is in flight, record one owed frame and stop
  • Otherwise call platform.wake(ReactiveWork { surfaces })
The event loop

Runs a frame; the frame calls flush(), which polls the task.

How background work wakes the event loop

The contract

// crates/zgui-reactive/src/executor/wake.rs
pub trait FrameWaker: Send + Sync + 'static {
    fn wake(&self);
}
pub fn set_frame_waker(waker: Arc<dyn FrameWaker>);
GuaranteeWhy
Callable from any threadThe wake arrives from wherever the work finished
Must not blockIt runs on that thread, inside whatever finished
Must not assume a current owner or the UI threadNeither exists on a worker thread
IdempotentA hundred wakes between two frames must cost one frame

The target is a thread-local Arc<WakeTarget> created eagerly and shared by every composite waker built on that thread, so installing a waker after effects already exist still reaches them.

RuntimeWaker is the implementation the framework installs, in crates/zgui-runtime/src/app.rs. It holds the surfaces the wake concerns: an image decoding for one window is not a reason to redraw another. Its idempotence comes from FrameGate, two atomic booleans shared with the frame loop. A test asserts the shape directly: a thousand in-frame wakes produce zero platform requests and exactly one owed frame (a_wake_inside_a_frame_is_owed_rather_than_sent, crates/zgui-runtime/src/wake.rs).

Note the two suppressions on that path, and that they are at different layers. frame::note_wake suppresses inside the reactive layer, for wakes raised while a flush is running. FrameGate suppresses inside the runtime, for anything raised anywhere in a frame — a mutation closing its batch, a timer's callback, an observation delivery. Each converts many requests into one.

Until set_frame_waker is called, a task woken from another thread is queued and nothing asks for the frame that would poll it. A host that drives the pipeline itself, rather than through zgui-runtime, must install one or it updates only when something else already caused a frame. TestWaker is a counting implementation for tests and headless harnesses: count() reads it, take() reads and resets.

Owners and arenas

An owner is the unit of "this went away".

// reactive_graph/src/owner.rs
pub(crate) struct OwnerInner {
    pub parent: Option<Weak<RwLock<OwnerInner>>>,
    nodes: Vec<NodeId>,
    pub contexts: FxHashMap<TypeId, Box<dyn Any + Send + Sync>>,
    pub cleanups: Vec<Box<dyn FnOnce() + Send + Sync>>,
    pub children: Vec<Weak<RwLock<OwnerInner>>>,
    paused: bool,
}

Creating an arena-backed handle inserts into the arena and then pushes the NodeId onto the current owner's nodes:

// reactive_graph/src/owner/arena_item.rs
let node = Arena::with_mut(|arena| arena.insert(Box::new(S::wrap(value)) as Box<dyn Any + Send + Sync>));
OWNER.with(|o| {
    if let Some(owner) = o.borrow().as_ref().and_then(|o| o.upgrade()) {
        owner.register(node);
    }
});

With no current owner the second half does nothing. The entry is in the arena, it works forever, and it is never removed. No panic and no log. Four published APIs guard against that case with assert_owner, and they are the complete list: provide_context, provide_local_context, on_cleanup_local and Selector::is_selected. RwSignal::new is not one of them.

Disposal is one function and always the same order:

Take cleanups, nodes and children out of the owner under one write lock, so a cleanup that touches the owner finds it readable rather than already borrowed.

Dispose of every child, recursively.

Run every cleanup, in registration order.

Remove every NodeId from the arena, under a single arena write lock for the whole batch.

Why disposal is synchronous

Mounted is the protocol: one owner per mounted node, and unmount disposes of everything before it returns.

// crates/zgui-reactive/src/own/mounted.rs
#[must_use = "an unmounted scope frees nothing; store it and call `unmount` when the node goes away"]
pub struct Mounted { /* private */ }

impl Mounted {
    #[track_caller] pub fn new() -> Self;
    pub fn with<T>(&self, build: impl FnOnce() -> T) -> T;
    pub fn owner(&self) -> &Owner;
    pub fn unmount(mut self);
}

Letting the effects behind a node's bindings drop their own scopes instead would defer every cleanup by one poll of the executor. That is one frame in which an unmounted node's cleanups have not run: a timer firing into a disposed scope, a row still holding its slot in a shared table, an observer reporting geometry for a node that is gone. Reading an arena-backed handle after its owner is disposed of panics, so the frame in between is not cosmetic.

Two further rules the type carries:

  • Drop disposes as well, so a scope dropped during a panic or by a container that owns it still frees. unmount is the intended path; Drop is the safety net.
  • A cleanup runs with no scope current, deliberately. Cleanups also run when the last handle to an owner is dropped, from anywhere, so a rule that held only on the unmount path would be a rule nothing could rely on. Everything a cleanup needs must be captured when it is registered.

Contexts live in the owner's own FxHashMap<TypeId, ...> and a lookup walks towards the root. The key is the type and nothing else, which is why a newtype is the rule rather than a suggestion.

Two debug-build growth alarms report, at error level through tracing, without panicking: MAX_OWNER_DEPTH = 4096 for the owner chain, and MAX_OWNER_CHILDREN = 1024 for one owner's child count. The second exists because of the next section.

Scope and generation retirement

An owner keeps a weak reference to every child it has ever had, and dropping a child does not remove it. children is a Vec that only grows. A long-lived parent with short-lived children — a list that scrolls, a table that filters, a route that changes — accumulates one dead entry per child ever created, and disposing of that parent eventually costs time proportional to everything it has ever held.

Scope fixes that by handing members out of a generation, and retiring generations in place.

// crates/zgui-reactive/src/own/scope.rs
const RETIRE_AFTER: usize = 64;

impl Scope {
    #[must_use] pub fn new() -> Self;
    #[track_caller] pub fn mount(&self) -> Mounted;
    #[must_use] pub fn live(&self) -> usize;
    #[must_use] pub fn generation_children(&self) -> usize;
    #[must_use] pub fn generations_created(&self) -> usize;
}

On each release, the newest generation is replaced when it holds more dead members than the whole scope holds live ones, with a floor of 64:

if current.created.saturating_sub(current.live) > self.live.get().max(RETIRE_AFTER) {
    generations.push(Generation { id, owner: self.parent.child(), created: 0, live: 0 });
}

Then every generation that is neither the newest nor still occupied is dropped whole.

Three properties, each load-bearing:

PropertyConsequence if broken
Generations are siblings under the scope's own owner, never nestedDropping a spent generation would dispose of the one that replaced it, and every live member in it
A live member never delays retirement and is never movedThe case that grows fastest — a virtualised list scrolling through a million rows — is also the case that always has rows on screen
A retired generation's owner is dropped only when its last member has goneIt then finds only dead references and cleans nothing

What is left is one dead entry per generation rather than per member. generation_children() is the number that would have grown without bound; generations_created() is the residual that still grows, far more slowly. A test mounts and unmounts 10 000 members and asserts generation_children() <= 65.

The For component does not use Scope. It implements the same policy directly over Owner, in crates/zgui-view/src/flow/each/generation.rs, retiring when a generation's dead count exceeds the list's live item count with no floor, and stating its own bound: dead references at most 2 × live + generations. Scope is the general form of the idea, published for code that keeps a churning sibling set of its own.

What Selector does internally

The problem is one shared piece of state that every row of a list compares against: "which one is selected?". Written the obvious way, every row reads the shared selected signal and compares it to its own key. Every row is therefore a subscriber of that signal, so changing the selection in a thousand-row list re-runs a thousand bindings to change two of them.

A memo per row is not enough. The memo does stop the row's closure from re-running when its own answer is unchanged, so only two closures run — but the shared signal still has a thousand subscribers. Every one of those memos is marked, every one of the thousand tasks behind them is polled, and every one recomputes its comparison. The work is O(rows) per selection change, and the saving is only the body of the binding.

The engine's Selector inverts the graph instead:

// reactive_graph/src/computed/selector.rs
pub struct Selector<T> where T: PartialEq + Eq + Clone + Hash + 'static {
    subs: Arc<RwLock<FxHashMap<T, ArcRwSignal<bool>>>>,   // one signal per watched key
    v: Arc<RwLock<Option<T>>>,                            // the last value of the source
    f: Arc<dyn Fn(&T, &T) -> bool + Send + Sync>,
    effect: Arc<RenderEffect<T>>,                         // the one subscriber of the source
}

The source has exactly one subscriber: the selector's own internal effect. A row calling selected(&key) tracks the small ArcRwSignal<bool> for its own key and nothing else. When the source changes, that effect walks the map once and notifies only the key that matches the new value and the key that matched the previous one. Two rows re-run instead of a thousand.

The walk is proportional to the number of watched keys, and the engine's selector never removes an entry. Left alone, every key a long-running list ever displayed stays in that map, and the walk grows with the history rather than with what is on screen.

So zgui's Selector wraps it and adds eviction:

// crates/zgui-reactive/src/reexport/selector.rs
pub struct Selector<K> where K: Eq + Hash + Clone + Send + Sync + 'static {
    inner: reactive_graph::computed::Selector<K>,
    watchers: Rc<RefCell<Watchers<K>>>,
}

struct Watchers<K> {
    counts: HashMap<K, usize>,               // how many scopes watch each key
    registered: HashSet<(usize, K)>,         // which (scope, key) pairs are already registered
}

is_selected(&key) calls watch(key) and then inner.selected(key). watch takes the current owner's debug_id() as the scope's identity, inserts the (scope, key) pair, and returns early if the pair was already there — so reading the same key twice in one row registers one cleanup, not two. It then bumps the per-key count and registers an on_cleanup_local that removes the pair, decrements the count, and calls inner.remove(&key) when the count reaches zero. A key two rows watch survives the first row's unmount.

Constructor or methodSignature
Selector::newpub fn new(source: impl Fn() -> K + Clone + Send + Sync + 'static) -> Self
Selector::new_with_fntakes matches: impl Fn(&K, &K) -> bool + Clone + Send + Sync + 'static, for keys not compared by equality — a path selected when it is a prefix of the current one, a range containing it
Selector::is_selectedpub fn is_selected(&self, key: &K) -> bool
Selector::watched_keyspub fn watched_keys(&self) -> usize — the diagnostic a long-running list's tests should assert on

Measured by the crate's own tests: eight rows, eight first runs, and after a selection change exactly two more (only_the_two_affected_rows_re_run). Ten thousand mount-and-unmount cycles leave watched_keys() == 0 (ten_thousand_rows_leave_nothing_behind).

With no current owner, is_selected calls assert_owner("Selector::is_selected"). That is a debug panic. In release builds the assertion compiles away, nothing can evict the entry, and it is kept for the life of the selector. Call it from the row's own scope.

Selector<K> holds Rc<RefCell<..>> internally, so it is not Send, even though K is required to be — that bound comes from the engine.

Storage

Every slot in the arena is a Box<dyn Any + Send + Sync>, because the map is one map for the process. Storage is how a value that is neither Send nor Sync gets in.

// reactive_graph/src/owner/storage.rs
pub trait Storage<T>: Send + Sync + 'static {
    type Wrapped: StorageAccess<T> + Send + Sync + 'static;
    fn wrap(value: T) -> Self::Wrapped;
    fn try_with<U>(node: NodeId, fun: impl FnOnce(&T) -> U) -> Option<U>;
    fn try_with_mut<U>(node: NodeId, fun: impl FnOnce(&mut T) -> U) -> Option<U>;
    fn try_set(node: NodeId, value: T) -> Option<T>;
    fn take(node: NodeId) -> Option<T>;
}
SyncStorageLocalStorage
WrappedTSendWrapper<T>
Bound on TSend + Sync + 'static'static
Downcast target in the arenaTSendWrapper<T>
Readable fromany threadthe creating thread only
Constructorsnew, signal, derivenew_local, signal_local, derive_local

Every handle type is generic over storage and defaults to SyncStorage. Trigger and ArcTrigger are the exceptions: they carry no value, so they have nothing to store.

The run-time check is not in the handle and not in the arena. It is in LocalStorage::try_with, which downcasts the slot to SendWrapper<T> and dereferences it. SendWrapper compares the current thread against the one it was constructed on and panics if they differ:

Dereferenced SendWrapper<T> variable from a thread different to the one it has been created with.

So the promise is checked per read, and it fires on the first read from the wrong thread rather than at creation. The same mechanism backs provide_local_context, which parks its value in a private Local<T>(SendWrapper<T>) newtype so that it cannot collide with a provide_context of the same type.

What is deliberately not published

Four engine APIs are unreachable from zgui-reactive's root, and the absence of each is asserted by a compile-fail test in crates/zgui-reactive/tests/ui/.

Not publishedWhat it would have doneUse instead
Effect::new_syncSchedules an effect that can run off the UI thread, where it may not touch the documentRenderEffect
Effect::new_isomorphicExists to run on a server as well as a client, which this framework has no notion ofRenderEffect
Owner::on_cleanupRequires impl FnOnce() + Send + Sync, so the first cleanup capturing anything from a view fails to compileon_cleanup_local
StoreFieldIterator (unkeyed store indexing)Re-runs every later sibling's observers, and panics with index out of bounds when the collection shrinks under a live observer#[store(key: K = ...)] and at_key

Two more are never re-exported at all, and the reason is the same each time: the substitute is better.

  • untrack(...) — the _untracked method family says the same thing at the read site, where the decision belongs. enter_non_reactive_zone is not a substitute; it suppresses a diagnostic.
  • batch(...) — unnecessary. A write never runs anything synchronously, and the one-bit notification slot coalesces every write between two flushes into at most one re-run per observer.

The engine crate itself is not re-exported from the root either, so neither is reachable through zgui. The exception is zgui_reactive::store::reactive_stores, which is published at its module path because #[derive(Store)] expands to reactive_stores:: paths in the deriving crate.

Costs

OperationWhat it touchesProportional to
get() on an arena signal, no observer2 arena read locks (disposal check, value clone), 1 value read lock, 1 clone of Tconstant
get() on an arena signal, tracked4 arena read locks (disposal check, build AnySource, add subscriber, value clone), 1 value read lock, 2 set insertionsconstant
get() on an ArcRwSignalno arena at all: 1 value read lock, plus the same 2 set insertions when trackedconstant
get_untracked()1 arena read lock, 1 value read lockconstant
set() / update()2 arena read locks, 1 value write lock, then clone the subscriber set and mark each entrynumber of direct subscribers
marking beyond the first edgeone mark_check per graph edge reachedsize of the observer sub-graph
flush() with nothing markedone thread-local claim, one run_until_stalled over an empty queuenothing
flush() with workone poll per ready task, one update_if_necessary walk per taskready tasks and their dependency sets
one effect re-rundispose its own owner, unsubscribe every old dependency, run, re-subscribeold dependencies + new dependencies + what the owner held
disposing an ownerchildren, then cleanups, then one arena write lock for all its nodeseverything ever registered under it

Three consequences worth stating outright:

  • The Arc handle types are the fast read. Four arena lock acquisitions per tracked read is the price of Copy. It is the right trade for a view, where handles are copied into closures constantly and read once per change; it is not the right trade for a hot inner loop.
  • maybe_update is the write that can decline to mark. Returning false calls untrack on the guard, so dropping it notifies nobody.
  • A memo is the only thing that stops propagation. Everything between a signal and an effect is marked; only a memo can answer "unchanged" and end the walk.

Measured on the framework's own workloads, all from docs/performance.md, which is generated by cargo xtask perf:

MeasurementValueWhat it is
kitchen.click11.34 µsOne class toggled on one element, whole frame, 1 851 boxes (band 15.12)
kitchen.keystroke301.39 µsOne edit: one paragraph reshaped, one box repainted (band 407.40)
idle.turn0.07 µsOne turn of the loop over a still document, which draws nothing
idle.frames0.00A still document runs no frame at all, so it runs no flush

The flush is not where a frame's time goes. The profile in docs/perf/pipeline.md put "the event loop, dispatch, the reactive flush, the cascade, timers, animation, accessibility and re-hit-testing together" at under 4 % of a click frame. Read that number as history: the click frame it was measured against cost 38 ms, and three defects it names have since been fixed. What survives is the shape — the reactive layer is a small, bounded phase of a frame, and there is no scan of the graph anywhere in it.

Next

On this page