This tutorial walks you through building viewmodels with the VMx Rust crate.
You will build a ComponentVm<Model>, a RelayCommand, and a CompositeVm<T>
with child selection — all in a plain Cargo binary.
The Rust flavor is a source-tree flavor at the v0.29.0 source line: it declares
MIN_SPEC_VERSION = "3.23.0"and carries behavioral tests for all 403 library conformance IDs. Thevmx-rscrate is not yet published to crates.io; consume it as a path or git dependency (below). See the Rust flavor page for current status and convergence evidence.For the contracts behind each type, see the component family, command families, and composite family.
The crate is named vmx-rs and exposes the module namespace vmx. It requires
Rust edition 2021 and a 1.88 toolchain floor. Because it is not yet on
crates.io, depend on it by path from a checkout:
[dependencies]
vmx-rs = { path = "langs/rust" }Or by git:
[dependencies]
vmx-rs = { git = "https://github.com/thekaveh/VMx.git" }The crate declares only serde and thiserror as runtime dependencies; the
reactive primitives are VMx-owned hot-stream facades, so no third-party reactive
runtime is pulled in.
Every viewmodel needs two services: a hub that carries messages between
viewmodels and a dispatcher that routes scheduled work. NullDispatcher runs
foreground and background work inline on the calling thread — the right choice
for tests and synchronous programs. It is Copy, so it can be handed to several
viewmodels without cloning.
DefaultDispatcher owns dedicated serial foreground and background workers.
ManualDispatcher provides separate foreground and background queues for
deterministic tests. NullDispatcher and ImmediateDispatcher deliberately run
both channels inline.
use vmx::{MessageHub, NullDispatcher};
fn main() {
let hub = MessageHub::new();
let dispatcher = NullDispatcher::new();
assert!(hub.history().is_empty());
let _ = dispatcher;
}Enable the paired-channel lifecycle path with
ComponentVm::builder().background(true) (or the read-only component builder).
Terminal state and publication are committed on foreground. Subscribe to the
hot background_errors() stream for hook failures after the fire-and-forget
call returns; the stream completes on disposal.
ComponentVm<M> is the primary leaf viewmodel. It holds a typed model, publishes
a property message on the hub when the model changes, and participates in the
lifecycle state machine. with_model constructs one directly; the model type
must be Clone + PartialEq.
use vmx::{ComponentVm, MessageHub, NullDispatcher, VmxResult};
#[derive(Clone, PartialEq)]
struct UserModel {
name: String,
email: String,
}
fn main() -> VmxResult<()> {
let hub = MessageHub::new();
let dispatcher = NullDispatcher::new();
let user = ComponentVm::with_model(
"user-card",
UserModel { name: "Alice".into(), email: "alice@example.com".into() },
hub.clone(),
dispatcher,
)
.with_model_hint(|model| Some(model.name.clone()));
user.construct()?;
assert!(user.is_constructed());
user.set_model(UserModel { name: "Alice Smith".into(), email: "asmith@example.com".into() });
assert_eq!(user.model().name, "Alice Smith");
assert_eq!(user.modeled_hint(), Some("Alice Smith".to_string()));
user.dispose()
}See the component family for the full contract.
RelayCommand wraps an action closure, an optional can_execute predicate, and
zero or more trigger hubs that announce when eligibility may have changed. Use
RelayCommand::new for the action-only form, or the builder to add a predicate.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use vmx::{Command, RelayCommand};
fn main() {
let is_dirty = Arc::new(AtomicBool::new(false));
let dirty_for_predicate = is_dirty.clone();
let save = RelayCommand::builder()
.action(|| println!("Saving..."))
.can_execute(move || dirty_for_predicate.load(Ordering::Acquire))
.build();
assert!(!save.can_execute());
is_dirty.store(true, Ordering::Release);
assert!(save.can_execute());
save.execute(); // prints "Saving..."
save.dispose();
}See command families for the full contract.
Commandis the object-safe trait exposingcan_execute()andexecute().
CompositeVm<T> owns an ordered child collection and a current selection slot.
Children come from a factory evaluated on the first construct(); an optional
current selector seeds the initial selection.
use vmx::{
ComponentVm, CompositeVm, FilteredCompositeVm, MessageHub, NullDispatcher, VmxResult,
};
fn build_tabs() -> VmxResult<()> {
let hub = MessageHub::new();
let dispatcher = NullDispatcher::new();
let home = ComponentVm::with_model("home-tab", "Home", hub.clone(), dispatcher);
let settings = ComponentVm::with_model("settings-tab", "Settings", hub.clone(), dispatcher);
let tabs = CompositeVm::builder()
.name("tab-bar")
.services(hub, dispatcher)
.children({
let children = vec![home.clone(), settings.clone()];
move || children.clone()
})
.current(|items| items.first().cloned())
.build()?;
tabs.construct()?;
assert_eq!(tabs.len(), 2);
assert_eq!(tabs.current().map(|c| c.name()), Some("home-tab".to_string()));
tabs.select_component(&settings)?;
assert_eq!(tabs.current().map(|c| c.name()), Some("settings-tab".to_string()));
let matches = FilteredCompositeVm::new(tabs.clone(), |tab| tab.model().contains("Home"));
assert_eq!(matches.visible_count(), 1);
tabs.dispose()
}
fn main() -> VmxResult<()> {
build_tabs()
}The example also projects a live filtered view with FilteredCompositeVm. See
the composite family for
the full contract.
Every viewmodel follows a five-state lifecycle —
Destructed -> Constructing -> Constructed -> Destructing -> Destructed — plus
the terminal Disposed. The mutating transitions return VmxResult<()>: an
illegal transition (for example constructing a disposed viewmodel) is a
catchable Err, not a panic, under the v3 lifecycle convergence (ADR-0053).
use vmx::{ComponentVm, ConstructionStatus, VmxResult};
fn main() -> VmxResult<()> {
let user = ComponentVm::new("user");
user.construct()?;
assert_eq!(user.status(), ConstructionStatus::Constructed);
user.reconstruct()?; // destruct + construct in one call; round-trips to Constructed
assert_eq!(user.status(), ConstructionStatus::Constructed);
user.destruct()?;
assert_eq!(user.status(), ConstructionStatus::Destructed);
user.dispose()?; // idempotent and terminal
assert_eq!(user.status(), ConstructionStatus::Disposed);
Ok(())
}Selecting a non-child returns Err(VmxError::NonChild) rather than trapping, so
callers can branch on the result. Builders return Err from build() when a
required field is missing.
See Lifecycle & Messaging for the lifecycle contract (
LIFE-001..015), including transition and disposal coordination.
| Resource | Documentation page |
|---|---|
| Specification status | Specification & Conformance |
| Lifecycle contract | Lifecycle & Messaging |
| Commands | Command Families |
| Component contract | Component Family |
| Composite contract | Composite Family |
| Architecture | Architecture Map |
| Rust status | Rust Flavor |
| Rust examples | Rust TUI Notes Showcase |