zgui

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 store

A 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

SituationUse
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.id

Every 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;
}
MethodWhat 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.

Next

On this page