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):
#![allow(unused)]
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>
Signal (Read-Only)
Signal<T> is a read-only reactive value (16 bytes, Copy). It cannot be written to — calling .set() is a compile-time error. There are two ways to create one:
#![allow(unused)]
fn main() {
// 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:
#![allow(unused)]
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:
#![allow(unused)]
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:
#![allow(unused)]
fn main() {
let label = create_memo(move || format!("Count: {}", count.get()));
text(label) // Only repaints when the formatted string changes
}
Effects
Side effects that re-run when tracked signals change:
#![allow(unused)]
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.
Using Signals in Widgets
Most widget properties accept either static values or reactive sources:
Static Value
#![allow(unused)]
fn main() {
container().background(Color::RED)
}
Signal
#![allow(unused)]
fn main() {
let bg = create_signal(Color::RED);
container().background(bg)
}
Closure
#![allow(unused)]
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:
#![allow(unused)]
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:
#![allow(unused)]
fn main() {
#[derive(Clone, PartialEq, SignalFields)]
pub struct AppState {
pub cpu: f64,
pub memory: f64,
pub title: String,
}
// 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:
#![allow(unused)]
fn main() {
let writers = state.writers();
let _ = create_service::<(), _, _>(move |_rx, 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:
#![allow(unused)]
fn main() {
#[derive(Clone, PartialEq, SignalFields)]
pub struct Pair<A: Clone + PartialEq + Send + 'static, B: Clone + PartialEq + Send + 'static> {
pub first: A,
pub second: B,
}
let pair = PairSignals::new(Pair { first: 1i32, second: "hello".to_string() });
}
Untracked Reads
Sometimes you want to read a signal without creating a dependency:
#![allow(unused)]
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.
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:
#![allow(unused)]
fn main() {
container().children(move || {
items.get().into_iter().map(|id| (id, move || {
// 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:
#![allow(unused)]
fn main() {
// 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:
#![allow(unused)]
fn main() {
// Setup
provide_context(Config::load());
// Any widget, any module
let cfg = expect_context::<Config>();
}
For mutable shared state, use provide_signal_context to combine context with reactivity.
Use Memo for Derived State
Instead of manually syncing values:
#![allow(unused)]
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);
}
API Reference
Signal Creation
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
// Register cleanup callback (for use in dynamic children)
pub fn on_cleanup(f: impl FnOnce() + 'static);
}
Background Services
#![allow(unused)]
fn main() {
// 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.