Stores and selectors
Per-field subscriptions for schema-shaped state, and one subscription per row for a large list.
A signal holding a struct wakes every reader when any field changes. A signal holding a selection wakes every row when the selection moves. Two tools fix those two problems. This page assumes Signals, Derived state and Control flow.
The problem a store solves
use zgui::prelude::*;
#[derive(Clone, Debug, PartialEq)]
struct Settings {
title: String,
width: u32,
}
let settings = RwSignal::new(Settings { title: "zgui".to_owned(), width: 800 });Two holes read it, one per field:
label {{move || settings.get().title.clone()}}
text {{move || settings.get().width.to_string()}}Writing the width wakes both, because both subscribed to the whole signal. The title hole re-runs,
produces the same string, and writes nothing to the document — so the frame is still cheap. But the
closure ran, the Settings was cloned, and the cost grows with the number of readers and the size
of the struct.
Three separate signals fix it and stop being practical at about six fields, because every function that takes "the settings" now takes six arguments.
A store is the third answer: one value with the shape of your struct, where each field has its own subscription.
A store
use zgui::reactive::store::reactive_stores;
use zgui::reactive::{Patch, Store};
#[derive(Clone, Debug, PartialEq, Store, Patch)]
struct Settings {
title: String,
width: u32,
}use zgui::reactive::store::reactive_stores; is mandatory in every module that derives Store.
The derive expands to reactive_stores::… paths inside your crate, so without the alias the build
fails with error[E0433]: cannot find module or crate 'reactive_stores' in this scope, repeated
once per generated path.
let store = Store::new(Settings {
title: "zgui".to_owned(),
width: 800,
});
let width = store.width(); // a field handle
width.set(900);
assert_eq!(width.get(), 900);#[derive(Store)] on Foo generates a trait FooStoreFields with one accessor per field. The
trait is in scope inside the module that declared the struct; across a module boundary you have to
use it.
Store::new needs T: Send + Sync + 'static. Store::new_local is the form for a T that is not.
Store<T> is Copy.
In a view
#[component]
fn SettingsPanel(store: Store<Settings>) -> impl IntoView {
let title = store.title();
let width = store.width();
view! {
column(class = "settings") {
label {{move || title.get()}}
text {{move || width.get().to_string()}}
control(on:click = move |_| width.update(|w| *w += 100)) {"Wider"}
}
}
}Clicking writes width. The title hole did not subscribe to it and does not run.
Never hand raw Store<T> to a reactive hole. An observer of the store root, or of any ancestor
field, re-runs on any descendant write. Read a specific field, or pass a Field<T> handle. The
component above is fine because the holes read title and width, not store.
Field handles as props
Field<T> and ArcField<T> are type-erased field handles. They are what a component prop should be
when the component needs one field and does not care where it came from.
#[component]
fn NumberRow(value: Field<u32>) -> impl IntoView {
view! {
row {
text {{move || value.get().to_string()}}
control(on:click = move |_| value.update(|v| *v += 1)) {"+"}
}
}
}Field converts from Store, Subfield, AtKeyed and the other field kinds, so a caller writes
value = store.width().into().
Keyed collections
A collection inside a store is keyed with a field attribute:
#[derive(Clone, Debug, PartialEq, Store, Patch)]
struct Settings {
title: String,
#[store(key: u64 = |row: &Row| row.id)]
rows: Vec<Row>,
}
#[derive(Clone, Debug, PartialEq, Store, Patch)]
struct Row {
id: u64,
value: f32,
}let row = store.rows().at_key(1);
row.value().set(2.0);Keyed access gives true per-item isolation: writing one row's value does not wake another row's
observers. A structural change — a push, a remove, a retain — invalidates every element observer,
which is correct, because positions moved.
#[store(skip)] suppresses the accessor for a field.
Unkeyed indexing is deliberately not published at the crate root, and you should not reach for
it. It fails two ways. It does not isolate siblings: writing plain[0].x re-runs the observer of
plain[1].x. And it panics on a stale index — an effect reading at_unkeyed(0) panics with
index out of bounds: the len is 0 but the index is 0 when the collection later becomes empty,
because the collection write notifies element observers before any of them are torn down.
Key your collections and address them by key.
Patching
Patch is a derive and a trait. store.patch(new_value) diffs the new value against the old one
and notifies only the fields that differ.
store.patch(Settings {
title: "zgui".to_owned(), // unchanged: wakes nothing
width: 1200, // changed: wakes the width observers only
rows: rows,
});This is the right tool when a whole value arrives at once — from a file, from a network response, from an undo step — and you do not want to write every field by hand.
Two limits worth knowing
Ancestors are always notified. An observer of a parent field re-runs on any descendant write. That is what makes "never expose the root" a rule rather than a preference.
The trigger map is monotonic. Entries are never removed. This is right for schema-shaped state of bounded size — settings, a theme, a document root. Do not key a store by something with unbounded cardinality.
An ergonomic gap
Partial· Option fields in a storeA store with an Option<T> field needs OptionStoreExt to call .unwrap() or .map() on it, and
that trait is only reachable through the engine alias:
use zgui::reactive::store::reactive_stores::OptionStoreExt;The same applies to Len, DerefField and ArcStore. They work; they are not re-exported at
the crate root.
When a store is worth it
| Situation | Use |
|---|---|
| Two or three independent values. | separate signals |
| One struct, one reader. | one RwSignal |
| A struct with many fields and many independent readers. | a store |
| A whole value that arrives at once and should not wake everything. | a store, with patch |
| A collection whose items change independently. | a store with a keyed field |
| State whose shape is not known ahead of time. | signals; a store needs a schema |
The problem a selector solves
A list of a thousand rows, one of which is selected. The obvious spelling:
let selected = RwSignal::new(0_usize);
// inside each row
class:selected = move || selected.get() == row.idEvery row read selected, so every row subscribed to it. Moving the selection wakes all thousand
rows. Two of them actually change.
A selector
use zgui::reactive::Selector;
let selected = RwSignal::new(0_usize);
let selector = Selector::new(move || selected.get());Selector<K> keeps one small signal per watched key. Asking is_selected subscribes the caller
to that key alone.
view! {
for row in move || rows.get(), key = |row: &Row| row.id {
row(
class = "row",
class:selected = move || selector.is_selected(&row.id),
on:click = move |_| selected.set(row.id)
) {
text {{row.label.clone()}}
}
}
}Moving the selection now notifies exactly two keys: the one that was selected and the one that now
is. Two rows re-run instead of a thousand. The repository asserts this directly: with eight
rows, eight first runs are followed by exactly two more when the selection moves
(only_the_two_affected_rows_re_run, crates/zgui-reactive/src/reexport/selector.rs).
The API
impl<K: Eq + Hash + Clone + Send + Sync + 'static> Selector<K> {
pub fn new(source: impl Fn() -> K + Clone + Send + Sync + 'static) -> Self;
pub fn new_with_fn(
source: impl Fn() -> K + Clone + Send + Sync + 'static,
matches: impl Fn(&K, &K) -> bool + Clone + Send + Sync + 'static,
) -> Self;
pub fn is_selected(&self, key: &K) -> bool;
pub fn watched_keys(&self) -> usize;
}| Method | What it does |
|---|---|
new(source) | a selector over a source of keys; two keys match when they are equal |
new_with_fn(source, matches) | the same, with a comparison of your own instead of equality |
is_selected(key) | whether this key is the selected one; subscribes the caller to this key alone and registers a cleanup in the current scope |
watched_keys() | how many keys are watched now; a diagnostic to assert returns to zero after a list has churned |
Selector<K> is Clone.
Eviction
is_selected registers an on_cleanup_local in the current scope, so unmounting a row removes
its entry. Reading the same key twice in one scope registers once, and a key watched by two rows
survives the first one's unmount.
This is why the wrapper exists at all: the underlying engine's selector never evicts, and a list
that churns would grow one entry per key for ever. The repository asserts that ten thousand mount
and unmount cycles leave watched_keys() at zero.
Calling is_selected with no current owner asserts in debug builds. In release the entry is kept
and never evicted. Call it inside a row's own scope, which is where you would write it anyway.