Reactivity

A Day component builds its controls when it appears. To change those controls later, keep the changing values in signals and read them inside reactive closures.

The distinction matters even for a label:

let count = Signal::new(0i64);

label(count.get().to_string());         // shows the value at construction
label(move || count.get().to_string()); // updates when count changes

The first call reads count immediately and passes a string to label. The second passes a closure that Day can run again. Updating the signal refreshes that label without calling the whole component function again.

Signals

A Signal<T> is a reactive cell. The handle is Copy; you move it into as many closures as you like without cloning it first:

let count = Signal::new(0i64);

count.get();                       // read (tracked — see below)
count.set(5);                      // write
count.update(|c| *c += 1);         // read-modify-write
count.with(|c| c.to_string());     // read by reference, no clone
count.get_untracked();             // read without subscribing

A read is tracked when it happens inside a reactive context: a memo, an effect, or one of the reactive closures you hand to Pieces. While the closure runs, Day records which signals it reads. Later changes to those signals tell Day to run the closure again.

let name = Signal::new(String::from("Ada"));

// The closure is a reactive context. It reads `name`, so it re-runs — and
// updates this one native label — whenever `name` changes.
label(move || format!("Hello, {}", name.get()))

Use an untracked read to get the current value without subscribing to changes. This is useful in effects that should not depend on every value they read. Event handlers are not reactive contexts, so ordinary reads there do not create subscriptions.

Memos

A Memo<T> is a derived value: computed from signals, cached, and only recomputed when a source actually changed. Observers of a memo re-run only when the memo’s output changes (PartialEq decides), which stops irrelevant updates from propagating:

let items = Signal::new(Vec::<Item>::new());
let total = Memo::new(move || items.with(|v| v.iter().map(|i| i.price).sum::<f64>()));

// Re-runs when the total changes — not on every items edit that leaves it equal.
label(move || format!("{:.2} €", total.get()))

Reading a memo gives you a value consistent with its current sources, even during an update. Use a memo for an expensive calculation or a value shared by several bindings. For a simple label that combines two signals, a closure is usually enough.

Effects and bindings

Effect::new(f) runs f now and re-runs it when any tracked read changes. Day itself uses the specialized forms below to wire widgets, and they’re available to you:

// compute (tracked) → apply (untracked), gated by PartialEq on the computed value.
bind(move || count.get() * 2, |doubled| println!("{doubled}"));

// watch: like bind, but you also get the previous value (an Option) and
// nothing runs at setup.
watch(move || route.get(), |new, old| log::info!("{old:?} → {new}"));

Every dynamic attribute in Day is one of these underneath. When you write label(move || …), the build step creates a binding whose apply-side patches one native widget:

count.set(3)
  │ marks observers dirty, queues their reactions
  ▼
binding for the label re-runs its compute closure   ← the only code that re-runs
  │ new string ≠ old string (PartialEq gate)
  ▼
apply: tree.patch(node, Text("3 clicks"))
  │
  ▼
toolkit.update(handle, patch)   →   NSTextField.stringValue = "3 clicks"

Only the label’s binding runs. The cost of a state change is proportional to the number of things that observe it.

Batching and the turn

Writes inside an event handler are batched: the handler runs to completion, then Day runs the pending reactions until no more updates remain. Layout then runs once for the affected controls, and native frames are updated. You can batch explicitly too:

batch(|| {
    first.set("Ada");
    last.set("Lovelace");
});
// Bindings that read both ran once, not twice.

The drain is synchronous and ordered (structural changes before attribute updates, outer scopes before inner). A cycle (an effect that keeps re-dirtying itself) trips a re-run cap rather than hanging: debug builds panic with the creation site of the offending effect; release builds warn and defer.

Scopes: ownership and cleanup

Every signal, memo, effect, and event handler is owned by the Scope that was current when it was created. Day’s structural Pieces manage scopes for you: each when arm and each each/list row gets a child scope, and when that arm or row goes away, disposing the scope tears down everything it owns: bindings stop firing, handlers are dropped, and the native widgets are released.

root scope
 ├─ page scope ("settings")
 │   ├─ binding: title label
 │   └─ when(logged_in) ── arm scope   ← disposed when the condition flips
 │                          ├─ binding: avatar image
 │                          └─ handler: logout tap
 └─ each(todos) row scopes, one per key ← disposed when the row's key disappears

Scopes also carry context: with_environment(value, || …) provides a value that environment::<T>() reads back anywhere below, which is how ambient configuration like theming works, and how app state is structured. A window’s content builds in a scope of its own, so a Copy struct of signal handles provided there is that window’s state: Scene::scoped(|s| …) to provide it, Scene::ambient() to read it back in any piece below, Scene::focused() for an app-wide menu bar whose items belong to no window. App state covers the full model, including why a thread_local! is the wrong default even when it looks equivalent.

Two rules follow from scope ownership.

  • A read with no observer never re-runs. Reading a signal in a plain function body computes the value once and forgets it. If you meant “keep this up to date”, the read has to be inside a binding, memo, or reactive closure.
  • Disposed handles: writing to a signal whose scope is gone is a defined no-op, warned once per call site (normal in async races, where a background task completes after the page closed). Reading one panics in every build and names the signal’s creation site.

Threads

The UI, the reactive graph, and the realized tree are single-threaded on the platform’s main thread. Signal is !Send, so the compiler stops you from moving one into a worker thread. Two paths lead back to the main thread:

let progress = Signal::new(0.0);
let set_progress = progress.setter();   // Setter<f64>: Send + Copy, write-only

std::thread::spawn(move || {
    for step in 0..100 {
        // heavy work…
        set_progress.set(step as f64 / 100.0);  // marshals to the main thread
    }
});

// Or run an arbitrary closure on the main thread:
on_main(move || { /* touch signals freely here */ });

A Setter checks liveness on arrival: if the target scope was disposed while the worker ran, the write drops silently. A download that finishes after its page was dismissed writes nothing.

Anything computed off the main thread comes back through a Setter or on_main, the same way it would with DispatchQueue.main.async or a Handler; Day expresses that rule as a type.

What this model asks of you

Keep changing values inside reactive closures. Use when for a conditional subtree and each or list for changing collections. A plain Rust if in a component function runs at construction, so changing a signal later will not switch its branch.

If a control is stuck on its initial value, first check where the signal is read. It usually needs to move inside the closure passed to that control.


Next: Layout, how measured, native-sized widgets end up in the right place.