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 owner | Example |
|---|---|
| every arena-backed handle | RwSignal, ReadSignal, WriteSignal, Memo, Signal |
| stored values | StoredValue |
| contexts | whatever provide_context was given |
| cleanup closures | whatever on_cleanup_local was given |
| child owners | one 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.
| Owner | Created by | Disposed when |
|---|---|---|
| the application root | the runtime, above every window | the application shuts down |
| the window root | the runtime, once per open surface | the window closes or the platform suspends it |
| one per component | the code #[component] generates around the call | that component's view is unmounted |
| one per list row | for / For | that row's key leaves the collection |
| one per branch | if / Show | the 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 panicThe rules:
Mounted::new()with no current owner is a root. Called inside another scope'swith, 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. Dropdisposes as a safety net, including while a panic unwinds.unmountis 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
unmountpath 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 processFour operations refuse to fail that quietly. Each calls assert_owner, which panics in debug
builds and compiles away to nothing in release:
| Operation | Debug panic message |
|---|---|
provide_context | provide_context requires a current owner: … |
provide_local_context | provide_local_context requires a current owner: … |
on_cleanup_local | on_cleanup_local requires a current owner: … |
Selector::is_selected | Selector::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):
| Constant | Value | Reported when |
|---|---|---|
MAX_OWNER_DEPTH | 4096 | one owner chain is deeper than this: owners are being nested without being disposed of |
MAX_OWNER_CHILDREN | 1024 | one 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.
| Function | Signature |
|---|---|
provide_context | fn provide_context<T: Send + Sync + 'static>(value: T) |
use_context | fn use_context<T: Clone + 'static>() -> Option<T> |
expect_context | fn expect_context<T: Clone + 'static>() -> T |
take_context | fn take_context<T: 'static>() -> Option<T> |
with_context | fn with_context<T: 'static, R>(cb: impl FnOnce(&T) -> R) -> Option<R> |
update_context | fn update_context<T: 'static, R>(cb: impl FnOnce(&mut T) -> R) -> Option<R> |
provide_local_context | fn provide_local_context<T: 'static>(value: T) |
use_local_context | fn 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 aprovide_context::<T>of the same type. You may provide both. take_context,with_contextandupdate_contextdo not see a local context: they look upT, and the wrapper is what is stored. Read a local context withuse_local_contextand 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:
Appcallsprovide_contextin its own body, so its own view sees it as well as everything below.- The context value is
Copy, because it holds signal handles rather than data.use_contextclones what it finds, so a cheap value is worth having. - The click handler in
Rowreadsselected, which it captured when the view was built. A listener could also callexpect_contextitself, 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
| Operation | Cost |
|---|---|
provide_context | one insert into the current owner's map, keyed by TypeId, plus one allocation for the box |
use_context hit | one hash lookup per owner from the reader up to the provider, then a clone of the value |
use_context miss | one hash lookup per owner all the way to the root, then None |
with_context | the 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
| Symptom | Cause |
|---|---|
expect_context panics naming your type | the consumer is not below the provider, or the provider runs after the consumer was built |
| A context lookup returns the wrong value | two 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 provided | it 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 changes | the value was replaced with provide_context instead of written through a signal inside it |