NativeDesktop

State

Entities and observation, controlled values, background work, clocks, errors and saved state.

State lives in entities. A view reads it in render; a handler changes it and calls cx.notify().

Example: Hello. The smallest app: a toolbar button, a count and window controls placed by coordinates. Source in examples/hello. It also runs live in the docs app.
Hello on macOS

Captured on macOS 27.0, 2026-10-14.

Models and observation

A model is an entity with no render. A view that shows it observes it, and the observer runs after the model calls cx.notify():

struct Tally {
    total: u32,
}

struct Readout {
    shown: u32,
}

impl Readout {
    fn new(tally: &Entity<Tally>, cx: &mut Context<Self>) -> Self {
        cx.observe(tally, |this, tally, cx| {
            this.shown = tally.read(cx).total;
            cx.notify();
        })
        .detach();
        Self { shown: tally.read(cx).total }
    }
}

impl Render for Readout {
    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
        text(format!("total {}", self.shown))
    }
}

From examples/hello/tests/state.rs, line 7.

cx.observe returns a Subscription. Dropping it ends the observation, so a constructor that wants the observer for the view's whole life calls .detach(). The observer gets &mut Self, the observed entity and a Context<Self>; it copies what it needs and notifies itself. The test changes the model from outside the view and reads the result off the test backend:

let tally = cx.new(|_| Tally { total: 0 });
let model = tally.clone();
let window = cx.open_window(Size { width: 400., height: 200. }, move |_, cx| cx.new(|cx| Readout::new(&model, cx)));
assert!(window_text(cx, window.id()).contains("total 0"));

tally.update(cx, |tally, cx| {
    tally.total += 3;
    cx.notify();
});
assert!(window_text(cx, window.id()).contains("total 3"));

From examples/hello/tests/state.rs, line 36.

Observation is how a child tells its parent that something changed: the shared model is the channel. A view does not emit typed events to its parent yet; there is no EventEmitter or cx.subscribe in nd-ui. Native controls have events (on_click, on_change, on_submit), and those reach your view through cx.listener.

Controlled values

Controls that hold a value (slider, select, text_field, tabs) are controlled. The app owns the value, passes it in on every render and writes the new one back in the change handler:

form().child(
    form_section()
        .title("Reading")
        .child(form_row("Density").child(
            select("density", ["Comfortable", "Compact"]).selected(self.density).on_change(cx.listener(
                |this, e: &SelectChanged, _, cx| {
                    this.density = e.index;
                    cx.notify();
                },
            )),
        ))
        .child(form_row("Text size").help("Applies to the message list").child(
            slider("size", self.size).range(11.0..=24.0).w(px(180.)).on_change(cx.listener(
                |this, e: &SliderChanged, _, cx| {
                    this.size = e.value;
                    cx.notify();
                },
            )),
        )),
)

From examples/chrome/src/lib.rs, line 133.

The framework keeps a record of what each native control holds and applies a value only when it differs. Setting the value the control already shows sends nothing, and events caused by the framework's own setter calls are dropped, so feeding a value back never produces a second set or an event loop. If a handler refuses a change by leaving the state alone, the control is set back to the app's value on the next frame. When the control normalises a value (a slider snapping to its step), the app receives the normalised value once.

Background work

There is no cx.spawn or executor timer in nd-ui yet. Off-thread work goes through nd::system::spawn(future, done): the future runs off the main thread, and done runs on the main thread with its output and the App. done may hold entities, so it holds a weak one and updates the view through it:

/// Runs `call` and logs its outcome under `what`.
fn run<T: std::fmt::Debug + Send + 'static>(
    &mut self,
    cx: &mut Context<Self>,
    what: &'static str,
    call: impl std::future::Future<Output = nd::system::Result<T>> + Send + 'static,
) {
    let view = cx.weak_entity();
    nd::system::spawn(call, move |result, app| {
        view.update(app, |this, cx| {
            match result {
                Ok(value) => this.note(format!("{what}: {value:?}")),
                Err(e) => this.note(format!("{what} failed: {e}")),
            }
            cx.notify();
        });
    });
}

From examples/system/src/lib.rs, line 123.

WeakEntity::update returns None when the view has closed, and the result is ignored. The same shape serves every callback that outlives the handler that created it, such as the answer of an alert or a file dialog:

fn delete(&mut self, _: &DeleteMessage, window: &mut Window, cx: &mut Context<Self>) {
    let this = cx.weak_entity();
    let alert =
        Alert::new("Delete this message?").message("It moves to the Trash.").destructive("Delete").cancel("Cancel");
    window.alert(cx, alert, move |answer, cx| {
        let _ = this.update(cx, |this, cx| {
            this.status = if answer.0 == 0 { "Deleted".into() } else { "Kept".into() };
            cx.notify();
        });
    });
}

From examples/chrome/src/lib.rs, line 20.

Call spawn from the main thread. In a tier 1 test no host runs the continuations, so call nd::system::run_pending(cx) after the future finishes; the system example's test does.

Clocks and timers

Use nd::time for Instant and SystemTime. On wasm32 the standard versions compile and then panic when called, and nd::time swaps in browser-safe ones. nd check flags std::time::Instant, std::time::SystemTime and std::thread::sleep in UI code.

Errors

Return Result from your own code and show failures in the UI: a Err from a system call is a value in done, as the run helper above logs it. A panic is not a UI state. On a panic, a native crash or a hang the diagnostics layer writes an incident bundle (UI tree, recent inputs, frame timings, logs) under the app data directory, and nd diagnose summarises the bundles. Call nd::report_issue("what happened") to write one on demand; it returns the bundle directory, or None on the web, where only the panic hook to the browser console exists.

Saved state

nd_settings::Store<T> keeps machine state such as window frames, recents or a library: JSON on disk as {"version": N, "data": ..}, written after a short debounce through a temp file and rename, and flushed on drop. Store::open(path, version, migrate) loads it. A missing file gives T::default(). A file that cannot be read or migrated is moved aside as <name>.corrupt-<unix time>, defaults win, and load_error() says why. get() returns an Arc<T> and update(|data| ..) changes it and schedules the write. On the web the same type writes to localStorage.

The store is not re-exported through nd and no example in the repository uses it yet, so there is no snippet here. Add nd-settings to your Cargo.toml to use it. User-facing options belong in the settings layer instead; see settings and config. The source is short and documents each call.