zgui

Context and ownership

What an owner is, when it frees what it holds, and how a value reaches a deep child without being passed through every component in between.

Every reactive value belongs to an owner, and the owner is what frees it when the piece of interface that made it goes away. Context uses the same tree of owners to hand a value down to a distant child. Ownership comes first here, because context is built on it. This page assumes Signals, Components and Effects and lifecycle.

What an owner is

An interface creates state while it is being built and has to destroy that state when the part of the interface that created it disappears. A closed panel must not leave its signals, its effects and its timers running. Nothing in Rust's own scoping helps here: a signal handle is Copy and gets moved into closures that outlive the function that made it, so the value it points at cannot be dropped by going out of scope.

An owner is the answer. It is a node in a tree of scopes. One owner is current at any moment. Anything reactive that is created attaches to the owner that is current at that instant, and disposing of that owner frees all of it at once.

What attaches to the current ownerExample
every arena-backed handleRwSignal, ReadSignal, WriteSignal, Memo, Signal
stored valuesStoredValue
contextswhatever provide_context was given
cleanup closureswhatever on_cleanup_local was given
child ownersone per nested component, list row or branch

Every other Copy handle in the reactive API behaves the same way. The reference-counted forms — ArcRwSignal, ArcMemo, ArcSignal and the rest — are the exception. They are freed when the last handle drops, and they attach to no owner.

A RenderEffect is a third case: its lifetime is its handle's, and dropping the handle cancels it immediately. The view layer stores that handle in the state it keeps for the node, so the effect dies with the node.

Disposal

Disposing of an owner does three things, in this order, synchronously, before the call returns:

Its child owners are disposed of, depth first.

Its cleanup closures run, in the order they were registered.

Its arena-backed values are dropped.

Synchronous is the load-bearing word. A deferred cleanup means one frame in which a removed node's timer still fires, its geometry observer still reports and its row still holds a slot in a shared table. Reading an arena-backed handle after its owner is disposed of panics, so that frame is not a cosmetic problem.

// Reading a handle after its owner was disposed of:
thread 'main' panicked:
Tried to access a reactive value that has already been disposed.

Where owners come from

You almost never create one. Five things create owners for you.

OwnerCreated byDisposed when
the application rootthe runtime, above every windowthe application shuts down
the window rootthe runtime, once per open surfacethe window closes or the platform suspends it
one per componentthe code #[component] generates around the callthat component's view is unmounted
one per list rowfor / Forthat row's key leaves the collection
one per branchif / Showthe condition flips

That is why a signal created in a component body is freed when the component leaves the interface, and why a row's signals are freed when the row is deleted rather than when the whole list is.

App::with_context runs in the application root. Use it for state that every window must resolve or that must survive suspension. A context provided by a component belongs to that component's window and is not visible in another window. On resume, each window root and its view are built again below the retained application root.

A listener runs in the scope it was written in, not in no scope at all. An event arrives from the platform, not from the reactive graph, so the owner current when the on: binding was created is captured once and made current again for each dispatch (crates/zgui-view/src/event/listener.rs). Without that, a context lookup inside a click handler would answer nothing and a signal created there would belong to nobody.

Mounted

Mounted is the protocol for a scope you own by hand. The runtime uses it for the window root, and a test that drives a view outside a window uses it directly.

impl Mounted {
    pub fn new() -> Self;
    pub fn with<T>(&self, build: impl FnOnce() -> T) -> T;
    pub fn owner(&self) -> &Owner;
    pub fn unmount(self);
}
use zgui::prelude::*;
use zgui::reactive::{Mounted, install};

install().unwrap();          // the runtime does this for you in an application
let node = Mounted::new();
let count = node.with(|| RwSignal::new(0));

count.set(1);
assert_eq!(count.get(), 1);

node.unmount();   // `count` is gone; reading it now would panic

The rules:

  • Mounted::new() with no current owner is a root. Called inside another scope's with, it is a child and dies with its parent.
  • The type is #[must_use]: a scope you drop on the floor frees nothing at the moment you meant.
  • Drop disposes as a safety net, including while a panic unwinds. unmount is the intended path.
  • A cleanup runs with no scope current, on purpose. 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.
  • In debug builds, mounting off the UI thread panics.

When there is no owner

This is the failure mode worth memorising, because it is silent.

Creating an arena-backed handle with no current owner succeeds. The handle works, reads and writes fine, and is never registered anywhere — so it is never freed and never disposed of. It is a permanent leak with no panic, no log and no symptom, in debug builds as well as release.

// No owner is current here.
let count = RwSignal::new(41);
count.set(42);
assert_eq!(count.get(), 42);   // it works, and it leaks for the life of the process

Four operations refuse to fail that quietly. Each calls assert_owner, which panics in debug builds and compiles away to nothing in release:

OperationDebug panic message
provide_contextprovide_context requires a current owner: …
provide_local_contextprovide_local_context requires a current owner: …
on_cleanup_localon_cleanup_local requires a current owner: …
Selector::is_selectedSelector::is_selected requires a current owner: …

The full text names the fix: with none, the value it creates is unreachable and never freed. Run it inside Mounted::with. The last row belongs to the selector helper that Stores and selectors covers; it is listed here because these four are the complete set.

A signal in a static never works. A lazily initialised global runs its initialiser at the first read, and that read happens either with no owner — in which case the signal leaks and no assertion fires — or inside whichever component happened to read it first, in which case the signal is disposed of when that unrelated component unmounts and every later read panics. Both outcomes are wrong, and which one you get depends on call order. Application state that outlives a component belongs in a context provided at the root, which the rest of this page covers.

Two more debug-only reports exist, at error level through tracing, that never panic (crates/zgui-reactive/src/executor/assert.rs):

ConstantValueReported when
MAX_OWNER_DEPTH4096one owner chain is deeper than this: owners are being nested without being disposed of
MAX_OWNER_CHILDREN1024one owner has accumulated more children than this: a generation is not being retired

Scope: retiring generations

An owner keeps a reference to every child ever created under it and removes none of them when they are disposed of. So a long-lived parent with short-lived children — a list that scrolls, a table that filters, a route that changes — grows one dead entry per child ever created. Disposing of that parent eventually costs time proportional to everything it has ever held, not to what it holds now.

Scope fixes that. It hands out members from a generation and retires generations whole.

use zgui::reactive::Scope;

impl Scope {
    pub fn new() -> Self;
    pub fn mount(&self) -> Mounted;
    pub fn live(&self) -> usize;
    pub fn generation_children(&self) -> usize;
    pub fn generations_created(&self) -> usize;
}
let scope = Scope::new();

// One row stays on screen for the whole scroll, and does not hold the others' storage.
let pinned = scope.mount();

for _ in 0..10_000 {
    let row = scope.mount();
    // ... build the row ...
    row.unmount();
}

assert_eq!(scope.live(), 1);
assert!(scope.generation_children() < 100);
pinned.unmount();

How it decides: a new generation is added beside the current one when the current one holds more dead members than the whole scope holds live ones, with a floor of RETIRE_AFTER = 64 (crates/zgui-reactive/src/own/scope.rs). Existing members never move. Generations are siblings under the scope's own owner, never nested — nesting would make dropping a spent generation dispose of the one that replaced it. A generation that is neither the newest nor still occupied is dropped whole. Live members therefore never delay retirement, which matters because the case that grows fastest is also the case that always has rows on screen. Ten thousand mount-and-unmount cycles leave fewer than a hundred entries behind, asserted by the type's own doctest.

You reach for Scope when you mount and unmount a changing set of sibling scopes yourself. If you write your lists with for, you never need it: the list keeps generations of its own by the same rule.

Context: the problem

A value needed by a deep child has to reach it. The obvious way is to pass it as a prop through every component in between:

#[component]
fn App() -> impl IntoView {
    let theme = RwSignal::new(false);
    view! { Page(theme = theme) }
}

#[component]
fn Page(theme: RwSignal<bool>) -> impl IntoView {
    // `Page` does not use `theme`. It only carries it.
    view! { Sidebar(theme = theme) }
}

#[component]
fn Sidebar(theme: RwSignal<bool>) -> impl IntoView {
    // Nor does `Sidebar`.
    view! { ThemeButton(theme = theme) }
}

Every intermediate component gains a prop it does not use. Adding one more consumer five levels down means editing five files. This gets worse with each shared value: a theme, a locale, a selection and a set of keyboard shortcuts turn into four dead props on every component in the middle.

Context removes the middle. A value is provided in one scope and read in any scope below it, however many owners apart they are. The components in between do not mention it.

#[component]
fn App() -> impl IntoView {
    provide_context(Theme(RwSignal::new(false)));
    view! { Page() }
}

#[component]
fn ThemeButton() -> impl IntoView {
    let Theme(dark) = expect_context::<Theme>();
    // ...
}

How lookup works

A context is keyed by its type. Each owner holds a map from TypeId to a boxed value. provide_context inserts into the current owner's map. A lookup starts at the current owner and walks towards the root, stopping at the first owner that holds a value of that type.

That gives three properties:

  • Providing shadows. A value provided in a nested scope hides an outer value of the same type, for that subtree only. This is how a nested list inside a list gives its rows their own selection.
  • Providing twice in one scope replaces. The second value wins.
  • A lookup never sees sideways or downwards. A sibling's context is invisible. A child's context is invisible to its parent.

A miss returns None rather than a default. That is deliberate: a wrong default is far harder to notice than a missing value.

The functions

All eight are in zgui::prelude.

FunctionSignature
provide_contextfn provide_context<T: Send + Sync + 'static>(value: T)
use_contextfn use_context<T: Clone + 'static>() -> Option<T>
expect_contextfn expect_context<T: Clone + 'static>() -> T
take_contextfn take_context<T: 'static>() -> Option<T>
with_contextfn with_context<T: 'static, R>(cb: impl FnOnce(&T) -> R) -> Option<R>
update_contextfn update_context<T: 'static, R>(cb: impl FnOnce(&mut T) -> R) -> Option<R>
provide_local_contextfn provide_local_context<T: 'static>(value: T)
use_local_contextfn use_local_context<T: Clone + 'static>() -> Option<T>

What each is for:

Prop

Type

There is no expect_local_context and no take_local_context.

update_context mutates the stored value in place. Nothing is tracked, so nothing re-runs. If a context value has to drive the interface, put a signal inside it and write the signal. That is what every example on this page does.

The newtype rule

A context is keyed by type, and the key space is the whole process. Use a newtype for anything whose type is not already specific to your use.

// Wrong: two unrelated features now fight over one key.
provide_context(String::from("en-GB"));   // the locale
provide_context(String::from("dark"));    // the theme, overwriting the locale

// Right.
#[derive(Clone)] struct Locale(String);
#[derive(Clone)] struct ThemeName(String);

provide_context(Locale("en-GB".into()));
provide_context(ThemeName("dark".into()));

The collision is silent. In the wrong version, use_context::<String>() in a component asking for the locale returns "dark", because the second provide overwrote the first in the same owner's map. bool, u32, String and Rc<str> are all bad keys for exactly this reason.

Values that are not Send

provide_context requires T: Send + Sync. Anything from the view layer is not: a node handle, a reference-counted callback, a backend handle. This is the second of the three Send escapes mentioned in Signals.

use std::rc::Rc;

#[derive(Clone)]
struct PanelHandle(Rc<str>);

provide_local_context(PanelHandle("panel".into()));
let handle = use_local_context::<PanelHandle>();

provide_local_context parks the value in a private wrapper that refuses to be touched from any thread but the one that created it. Two consequences:

  • The key is the wrapper, not T, so a local context cannot collide with a provide_context::<T> of the same type. You may provide both.
  • take_context, with_context and update_context do not see a local context: they look up T, and the wrapper is what is stored. Read a local context with use_local_context and nothing else.

The framework uses this itself: a window provides its host handle with provide_local_context, which is how set_timeout and focused_node are free functions that still find the right window in a process with two of them (crates/zgui-view/src/cx/mod.rs).

A worked example

A theme and a selection, provided once at the root and read at two different depths. Built from the element vocabulary only.

use zgui::prelude::*;

/// The theme, as a newtype over the signal that drives it.
#[derive(Clone, Copy)]
struct Theme(RwSignal<bool>);

/// Which row is selected.
#[derive(Clone, Copy)]
struct Selection(RwSignal<Option<u64>>);

#[component]
fn App() -> impl IntoView {
    provide_context(Theme(RwSignal::new(false)));
    provide_context(Selection(RwSignal::new(None)));

    let Theme(dark) = expect_context::<Theme>();

    view! {
        column(class = "app", class:dark = move || dark.get()) {
            Toolbar()
            List()
        }
    }
}

#[component]
fn Toolbar() -> impl IntoView {
    // Two levels from the provider, and `App` passed it nothing.
    let Theme(dark) = expect_context::<Theme>();

    view! {
        row(class = "toolbar") {
            control(on:click = move |_| dark.update(|on| *on = !*on)) {"Toggle theme"}
        }
    }
}

#[component]
fn List() -> impl IntoView {
    let rows = RwSignal::new(vec![1_u64, 2, 3]);

    view! {
        column(class = "list") {
            for id in move || rows.get(), key = |id: &u64| *id {
                Row(id = id)
            }
        }
    }
}

#[component]
fn Row(id: u64) -> impl IntoView {
    // Each row reads the same selection and writes it. `List` never mentions it.
    let Selection(selected) = expect_context::<Selection>();

    view! {
        row(
            class = "row",
            class:selected = move || selected.get() == Some(id),
            on:click = move |_| selected.set(Some(id)),
        ) {
            text {{format!("Row {id}")}}
        }
    }
}

Three things this shows:

  1. App calls provide_context in its own body, so its own view sees it as well as everything below.
  2. The context value is Copy, because it holds signal handles rather than data. use_context clones what it finds, so a cheap value is worth having.
  3. The click handler in Row reads selected, which it captured when the view was built. A listener could also call expect_context itself, because it runs in the scope it was written in.

Wrap the provider in a component of your own when the setup is more than one line — a ThemeProvider taking children: Children, calling provide_context and returning {children.into_view_once()}. Everything below it in the view is inside its scope, so everything below sees the context.

What context costs

OperationCost
provide_contextone insert into the current owner's map, keyed by TypeId, plus one allocation for the box
use_context hitone hash lookup per owner from the reader up to the provider, then a clone of the value
use_context missone hash lookup per owner all the way to the root, then None
with_contextthe same walk, without the clone

The walk is proportional to the number of owners between the reader and the provider — the component nesting depth at that point, not the size of the document. Each step is a hash lookup on a TypeId behind a read lock.

Two rules follow:

  • Look the context up once, in the component body. Bind the result to a local and use it. A lookup inside a reactive hole repeats the walk on every run for no gain.
  • A context lookup is not a reactive read. It subscribes to nothing and it wakes nothing. Providing a new value of the same type in the same scope does not update anything that already read the old one. Reactivity comes from the signal inside the value, never from the lookup.

Common mistakes

SymptomCause
expect_context panics naming your typethe consumer is not below the provider, or the provider runs after the consumer was built
A context lookup returns the wrong valuetwo features share a key type; use a newtype
Providing panics in debug with "requires a current owner"provide_context ran outside any component body
A local context returns None when one is clearly providedit was provided with provide_context and read with use_local_context, or the reverse
Reading a value panics with "already been disposed"the owner that created it was disposed of; the handle outlived its scope
Nothing updates when the context value changesthe value was replaced with provide_context instead of written through a signal inside it

Next

On this page