Reactive Model
Guido uses a fine-grained reactive system inspired by SolidJS. This enables efficient updates where only the affected parts of the UI change.
RwSignal (Read-Write)
create_signal() returns an RwSignal<T> — a read-write reactive value (8 bytes, Copy):
extern crate guido;
fn main() {
use guido::prelude::*;
let count = create_signal(0); // RwSignal<i32>
// Read the current value
let value = count.get();
// Set a new value
count.set(5);
// Update based on current value
count.update(|c| *c += 1);
;
}
Key Properties
- Copy -
RwSignalimplementsCopy, so you can use it in multiple closures without cloning - Background updates - Use
.writer()to get aWriteSignal<T>for background task updates - Automatic tracking - Dependencies are tracked when reading inside reactive contexts
- Converts to Signal - Call
.read_only()or.into()to get a read-onlySignal<T>
State vs. Triggers: set and set_always
set publishes state: equal values are deduplicated (PartialEq),
so republishing unchanged data costs nothing. set_always fires a
trigger: every write notifies, no comparison, no PartialEq bound —
the natural write for “something happened” values like an OSD flash:
extern crate guido;
use guido::prelude::*;
fn main() {
let volume = create_signal(0u32);
let osd = create_signal(String::new());
let info = String::from("volume 50%");
volume.set(50); // state: setting 50 twice notifies once
osd.set_always(info); // trigger: every flash notifies
;
}
For a data-less pulse there is Trigger:
extern crate guido;
use guido::prelude::*;
fn main() {
let rebuild = move || {};
let refresh = create_trigger();
create_effect(move || {
refresh.track();
rebuild();
});
refresh.notify(); // rebuild() runs every time
;
}
Signals hold one value, so rapid set_always calls coalesce to the last
one per frame — perfect for UI triggers, wrong for event streams that
must not lose emissions (those belong in an async channel).
Signal (Read-Only)
Signal<T> is a read-only reactive value (12 bytes, Copy). It cannot be written to — calling .set() is a compile-time error. There are two ways to create one:
extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);
// Stored: wraps a static value
let name = create_stored("hello".to_string()); // Signal<String>
// Derived: closure-backed, re-evaluates on each read
let doubled = create_derived(move || count.get() * 2); // Signal<i32>
;
}
You can also convert an RwSignal to a Signal:
extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);
let read_only: Signal<i32> = count.read_only(); // or count.into()
;
}
Memos
Eagerly computed values that automatically update when their dependencies change. Memos only notify downstream subscribers when the result actually differs (PartialEq), preventing unnecessary updates:
extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);
let doubled = create_memo(move || count.get() * 2);
count.set(5);
println!("{}", doubled.get()); // Prints: 10
;
}
Memos are Copy like signals and can be used directly as widget properties:
extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);
let label = create_memo(move || format!("Count: {}", count.get()));
text(label) // Only repaints when the formatted string changes
;
}
A memo is current whenever an effect that reads it runs. Take an effect that reads a list’s length and a memo clamping an index into it: it runs once per write, and sees the new length beside the new index, never the old one.
Effects
Side effects that re-run when tracked signals change:
extern crate guido;
use guido::prelude::*;
fn main() {
let name = create_signal("World".to_string());
create_effect(move || {
println!("Hello, {}!", name.get());
});
name.set("Guido".to_string()); // Effect re-runs, prints: Hello, Guido!
;
}
Effects are useful for logging, syncing with external systems, or triggering actions.
Each run owns what it makes. A signal, an effect, a timer, a task, an
on_cleanup or a context provided inside the body is disposed before the effect runs again,
and with the effect when its scope goes. A memo is an effect too, so the same
holds for what a memo’s closure makes. To keep something past the run, make it
under a scope captured outside:
extern crate guido;
use guido::prelude::*;
fn main() {
let trigger = create_signal(0);
let outer = current_owner().expect("created inside a scope");
create_effect(move || {
trigger.get();
let kept = outer.run(|| create_signal(0)); // lives as long as `outer`
});
}
Using Signals in Widgets
Most widget properties accept either static values or reactive sources:
Static Value
extern crate guido;
use guido::prelude::*;
fn main() {
container().background(Color::RED)
;
}
Signal
extern crate guido;
use guido::prelude::*;
fn main() {
let bg = create_signal(Color::RED);
container().background(bg)
;
}
Closure
extern crate guido;
use guido::prelude::*;
fn main() {
let is_active = create_signal(false);
container().background(move || {
if is_active.get() { Color::GREEN } else { Color::RED }
})
;
}
Reactive Text
Text content can be reactive using closures:
extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);
text(move || format!("Count: {}", count.get()))
;
}
The text automatically updates when count changes.
The IntoSignal Pattern
Widget properties accept Signal<T> via the IntoSignal<T> trait. Both RwSignal<T> and Signal<T> work, along with raw values and closures:
- Static values → creates a stored
Signal<T>viacreate_stored() - Closures → creates a derived
Signal<T>viacreate_derived() Signal<T>→ passed through directlyRwSignal<T>→ automatically converted toSignal<T>
You don’t need to create signals manually for widget properties — just pass values, closures, or signals directly.
Per-Field Signals
When multiple widgets depend on different fields of the same struct, #[derive(SignalFields)] generates per-field signals so each widget only re-renders when its specific field changes:
extern crate guido;
use guido::prelude::*;
#[derive(Clone, PartialEq, SignalFields)]
pub struct AppState {
pub cpu: f64,
pub memory: f64,
pub title: String,
}
fn main() {
// Creates individual Signal<T> for each field
let state = AppStateSignals::new(AppState {
cpu: 0.0, memory: 0.0, title: "App".into(),
});
// Each widget subscribes to only the field it reads
text(move || format!("CPU: {:.0}%", state.cpu.get()));
text(move || format!("MEM: {:.0}%", state.memory.get()));
text(move || state.title.get());
}
Use .writers() to get Send handles for background task updates:
// not compiled: the task body is tokio's, and the book's samples are
// compiled without it.
extern crate guido;
use guido::prelude::*;
fn main() {
let writers = state.writers();
create_task(move |ctx| async move {
while ctx.is_running() {
// Each field is set individually with PartialEq change detection.
// Effects that depend on multiple fields run only once (batched).
writers.set(AppState {
cpu: read_cpu(),
memory: read_memory(),
title: get_title(),
});
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
;
}
Generic structs are supported — the generated types carry the same generic parameters:
extern crate guido;
use guido::prelude::*;
#[derive(Clone, PartialEq, SignalFields)]
pub struct Pair<A: Clone + PartialEq + Send + 'static, B: Clone + PartialEq + Send + 'static> {
pub first: A,
pub second: B,
}
fn main() {
let pair = PairSignals::new(Pair { first: 1i32, second: "hello".to_string() });
let _ = pair;
}
Untracked Reads
Sometimes you want to read a signal without creating a dependency:
extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);
// Normal read - creates dependency
let value = count.get();
// Untracked read - no dependency
let value = count.get_untracked();
;
}
This is useful in effects where you want to read initial values without re-running on changes.
Timers
To do something later on the UI thread — hide a toast after a while, show a
tooltip after a hover, search once typing pauses — use set_timeout and
set_interval. The callback runs on the UI thread, so it can read and write
signals like any other UI code:
extern crate guido;
use guido::prelude::*;
use std::time::Duration;
fn main() {
let visible = create_signal(true);
let auto_close = set_timeout(Duration::from_secs(4), move || visible.set(false));
// A close button hides it at once, and stops the timer that would have.
let close = container().on_click(move || {
auto_close.cancel();
visible.set(false);
});
let seconds = create_signal(0u32);
set_interval(Duration::from_secs(1), move || seconds.update(|s| *s += 1));
}
A timer stops when it is cancelled or when the scope that created it is
disposed — a widget’s timers go with the widget. Dropping the TimerHandle does
not stop it. What an effect’s run makes goes when it runs again, so an effect
that schedules a timer on every run fires only the last — which is how a
debounce is written:
extern crate guido;
use guido::prelude::*;
use std::time::Duration;
fn main() {
let search = |_q: String| {};
let query = create_signal(String::new());
create_effect(move || {
let q = query.get();
set_timeout(Duration::from_millis(300), move || search(q));
});
}
Background work that has to leave the UI thread — a socket, a file — is
create_task instead.
Ownership & Cleanup
Signals and effects created inside dynamic children are automatically cleaned up when the child is removed. Use on_cleanup to register custom cleanup logic:
extern crate guido;
use guido::prelude::*;
fn main() {
let items = create_signal(vec![1u32, 2, 3]);
container().children(keyed(
move || items.get(),
|id| *id,
|id| {
// These are automatically owned and disposed
let count = create_signal(0);
create_effect(move || println!("Count: {}", count.get()));
// Register custom cleanup for non-reactive resources
on_cleanup(move || {
println!("Child {} removed", id);
});
container().child(text(move || count.get().to_string()))
},
))
;
}
See Dynamic Children for more details on automatic ownership.
Best Practices
Read Close to Usage
Read signals where the value is needed, not at the top of functions:
extern crate guido;
use guido::prelude::*;
fn main() {
let count = create_signal(0);
// Good: Read in closure where it's used
text(move || format!("Count: {}", count.get()));
// Less optimal: Read early, pass static value
let value = count.get();
text(format!("Count: {}", value)) // Won't update!
;
}
Use Context for App-Wide State
For values that many widgets across different modules need (config, theme, services), use the Context API instead of passing signals through every function:
extern crate guido;
use guido::prelude::*;
#[derive(Clone, Default)]
struct Config;
impl Config { fn load() -> Self { Self } }
fn main() {
// In the App::run setup, which is the root scope
provide_context(Config::load());
// In any widget factory below it, in any module
let cfg = expect_context::<Config>();
;
}
A value is declared for the scope that is current — the setup, a surface, a popup, a dynamic list — and read by the scopes below it. For mutable shared state, use provide_signal_context to combine context with reactivity.
Use Memo for Derived State
Instead of manually syncing values:
extern crate guido;
use guido::prelude::*;
fn main() {
// Bad: Manual sync
let count = create_signal(0);
let doubled = create_signal(0);
// Must remember to update doubled when count changes
// Good: Use memo
let count = create_signal(0);
let doubled = create_memo(move || count.get() * 2);
;
}
A memo is also a tracking barrier, which is what makes it the tool for narrowing a rebuild. Signals read inside it belong to it alone, even when the memo is created inside something that is itself tracked — a dynamic-children closure, a paint pass, an effect:
extern crate guido;
use guido::prelude::*;
fn expensive_module() -> Container { container() }
fn main() {
let levels = create_signal(vec![0.2f32, 0.5, 0.9]);
// `levels` is written 60 times a second by an audio service
container().child(move || {
let active = create_memo(move || levels.with(|l| !l.is_empty()));
// This closure depends on the bool, not on `levels`: the subtree is
// rebuilt when the bool flips, not on every write
active.get().then(|| expensive_module())
})
;
}
Snapshots vs. Reactive Reads
Reading a signal subscribes whoever is currently running. Inside a closure that guido re-runs — a widget property, a child closure, an effect — that means the UI follows the value. Outside one, there is nobody to subscribe, so the read is a snapshot:
extern crate guido;
use guido::prelude::*;
fn main() {
let count_as_string = create_memo(move || String::new());
let count = create_signal(0);
text(format!("{}", count.get())); // snapshot: never updates again
text(move || format!("{}", count.get())); // reactive
text(count_as_string) // reactive: pass the signal itself
;
}
Rust cannot tell those apart at compile time, so debug builds print a warning at the offending line:
guido: src/main.rs:42:31: signal read with no reactive scope — this value is a
snapshot and will not update. Pass a closure instead …
If the snapshot is what you meant, say so with get_untracked() and the
warning goes away. Release builds contain none of this.
API Reference
Signal Creation
// not compiled: a signature listing — these declarations have no bodies.
pub fn create_signal<T: Clone + PartialEq + Send + 'static>(value: T) -> RwSignal<T>;
pub fn create_stored<T: Clone + 'static>(value: T) -> Signal<T>;
pub fn create_derived<T: Clone + 'static>(f: impl Fn() -> T + 'static) -> Signal<T>;
pub fn create_memo<T: Clone + PartialEq + 'static>(f: impl Fn() -> T + 'static) -> Memo<T>;
pub fn create_effect(f: impl Fn() + 'static);
RwSignal Methods
// not compiled: a signature listing — these declarations have no bodies.
impl<T: Clone> RwSignal<T> {
pub fn get(&self) -> T; // Read with tracking
pub fn get_untracked(&self) -> T; // Read without tracking
pub fn set(&self, value: T); // Set new value
pub fn update(&self, f: impl FnOnce(&mut T)); // Update in place
pub fn writer(&self) -> WriteSignal<T>; // Get Send handle for background threads
pub fn read_only(&self) -> Signal<T>; // Convert to read-only Signal
}
Signal Methods
// not compiled: a signature listing — these declarations have no bodies.
impl<T: Clone> Signal<T> {
pub fn get(&self) -> T; // Read with tracking
pub fn get_untracked(&self) -> T; // Read without tracking
pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R; // Borrow with tracking
pub fn with_untracked<R>(&self, f: impl FnOnce(&T) -> R) -> R; // Borrow without tracking
// No set/update/writer — Signal is read-only
}
Memo Methods
// not compiled: a signature listing — these declarations have no bodies.
impl<T: Clone + PartialEq> Memo<T> {
pub fn get(&self) -> T; // Read with tracking
pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R; // Borrow with tracking
}
Cleanup
// not compiled: a signature listing — these declarations have no bodies.
// Register cleanup callback (for use in dynamic children)
pub fn on_cleanup(f: impl FnOnce() + 'static);
Timers
// not compiled: a signature listing — these declarations have no bodies.
// Run once after a delay, on the UI thread; cancelled with its scope
pub fn set_timeout(delay: Duration, f: impl FnOnce() + 'static) -> TimerHandle;
// Run every period, on the UI thread; cancelled with its scope
pub fn set_interval(period: Duration, f: impl FnMut() + 'static) -> TimerHandle;
// TimerHandle is Copy: .cancel(), .is_pending()
Background Services
// not compiled: a signature listing — these declarations have no bodies.
// Create an async background service with automatic cleanup
pub fn create_service<Cmd, F, Fut>(f: F) -> Service<Cmd>
where
Cmd: Send + 'static,
F: FnOnce(UnboundedReceiver<Cmd>, ServiceContext) -> Fut + Send + 'static,
Fut: Future<Output = ()> + Send + 'static;
See Background Tasks for detailed usage.