3.4. Getting Started with VMx — TypeScript¶
This tutorial walks you through building viewmodels with the VMx TypeScript
library. You will build a ComponentVMOf<UserModel>, a RelayCommand with a
reactive trigger, and a CompositeVM<TabVM> with tab selection — all in a Node
script or test.
For the contracts behind each type, see the component family, command families, and composite family.
3.4.1. Install¶
The source tree currently implements v3.24.0. The npm package is not published
yet; use the package command after a typescript-v* release publishes it.
npm install @thekaveh/vmx rxjs
For local development from a checked-out clone:
npm install /path/to/VMx/langs/typescript
@thekaveh/vmx (renamed in v2.4.0 from the unscoped vmx name, which was
unavailable on the npm registry) ships dual ESM + CJS bundles and full
TypeScript declarations. No extra @types/vmx package is needed.
3.4.2. Wire up MessageHub and RxDispatcher¶
Every viewmodel needs two services: a hub that carries messages between viewmodels and a dispatcher that knows about your scheduler pair.
3.4.2.1. Option A — immediate (Node scripts / synchronous tests)¶
import { MessageHub, RxDispatcher } from "@thekaveh/vmx";
const hub = new MessageHub();
const dispatcher = RxDispatcher.immediate();
// Both foreground and background use queueScheduler (synchronous).
// Safe for Node scripts and vitest suites with no async event loop.
3.4.2.2. Option B — custom schedulers (browser / async environments)¶
import { asyncScheduler, animationFrameScheduler } from "rxjs";
import { MessageHub, RxDispatcher } from "@thekaveh/vmx";
const hub = new MessageHub();
const dispatcher = new RxDispatcher(
animationFrameScheduler, // foreground — UI thread / rAF
asyncScheduler, // background — macro-task queue
);
3.4.3. Build a ComponentVMOf<UserModel>¶
ComponentVMOf<M> is the primary leaf viewmodel. It holds a typed model, fires
PropertyChangedMessage on the hub when the model changes, and participates in
the lifecycle state machine
(Destructed → Constructing → Constructed → Destructing → Destructed).
import {
ComponentVMOf,
MessageHub,
PropertyChangedMessage,
RxDispatcher,
} from "@thekaveh/vmx";
interface UserModel {
name: string;
email: string;
}
const hub = new MessageHub();
const dispatcher = RxDispatcher.immediate();
// Build the viewmodel — every builder setter returns a NEW builder (immutable).
const userVM = ComponentVMOf.builder<UserModel>()
.name("user-card")
.model({ name: "Alice", email: "alice@example.com" })
.services(hub, dispatcher)
// Derive a display hint from the model.
.modeledHinter((m) => m.name)
// Optional callbacks.
.onConstruct(() => console.log("user-card constructed"))
.onDestruct(() => console.log("user-card destructed"))
.build();
// Subscribe to hub messages BEFORE constructing.
hub.messages.subscribe((msg) => {
if (msg instanceof PropertyChangedMessage && msg.sender === userVM) {
console.log(`Property '${msg.propertyName}' changed on ${msg.senderName}`);
}
});
// construct() transitions Destructed → Constructing → Constructed.
userVM.construct();
// stdout: "user-card constructed"
// Update the model.
userVM.model = { name: "Alice Smith", email: "asmith@example.com" };
// stdout: "Property 'model' changed on user-card"
console.log(userVM.modeledHint); // "Alice Smith"
console.log(userVM.isConstructed); // true
See the component family for the full component contract and Services, Messages & Dispatching for the
PropertyChangedMessageschema.
3.4.4. Build a RelayCommand¶
RelayCommand wraps an optional execute callback, an optional canExecute
predicate, and a set of RxJS Observable triggers that signal canExecute may
have changed.
import { Subject } from "rxjs";
import { RelayCommand } from "@thekaveh/vmx";
const canSaveTrigger = new Subject<void>();
let isDirty = false;
const saveCommand = RelayCommand.builder()
.task(() => {
console.log("Saving…");
isDirty = false;
canSaveTrigger.next(); // re-evaluate canExecute
})
.predicate(() => isDirty)
.triggers(canSaveTrigger)
.build();
console.log(saveCommand.canExecute()); // false
isDirty = true;
canSaveTrigger.next(); // fires canExecuteChanged
saveCommand.canExecuteChanged.subscribe(() =>
console.log(` canExecute is now ${saveCommand.canExecute()}`)
);
console.log(saveCommand.canExecute()); // true
saveCommand.execute(); // prints "Saving…"
console.log(saveCommand.canExecute()); // false again
// Dispose to unsubscribe all trigger subscriptions.
saveCommand.dispose();
See command families for the full command contract.
3.4.5. Build a CompositeVM<TabVM>¶
CompositeVM<VM> owns an ordered child collection and a current selection
slot. Children are provided by a factory that runs on the first construct()
call.
import {
ComponentVMOf,
CompositeVM,
MessageHub,
PropertyChangedMessage,
RxDispatcher,
} from "@thekaveh/vmx";
interface TabModel {
title: string;
}
const hub = new MessageHub();
const dispatcher = RxDispatcher.immediate();
const tab1 = ComponentVMOf.builder<TabModel>()
.name("home-tab")
.model({ title: "Home" })
.services(hub, dispatcher)
.build();
const tab2 = ComponentVMOf.builder<TabModel>()
.name("settings-tab")
.model({ title: "Settings" })
.services(hub, dispatcher)
.build();
const tabs = CompositeVM.builder<ComponentVMOf<TabModel>>()
.name("tab-bar")
.services(hub, dispatcher)
.children(() => [tab1, tab2])
.onConstruct(() => console.log("tab-bar ready"))
.build();
// Watch for current-selection changes via the hub.
hub.messages.subscribe((msg) => {
if (msg instanceof PropertyChangedMessage && msg.sender === tabs) {
if (msg.propertyName === "current") {
const title = tabs.current ? tabs.current.model.title : "(none)";
console.log(`Selected tab: ${title}`);
}
}
});
// construct() cascades: the composite constructs itself then each child.
tabs.construct();
// stdout: "tab-bar ready"
// Select a tab — publishes PropertyChangedMessage for "current" and
// sets child.isCurrent.
tabs.current = tab2; // stdout: "Selected tab: Settings"
tabs.current = tab1; // stdout: "Selected tab: Home"
console.log([...tabs].map((c) => c.name)); // ["home-tab", "settings-tab"]
console.log(tab2.isCurrent); // false
See the composite family for the full
CompositeVMcontract, includingCollectionChangedEventandBatchUpdatesemantics.
3.4.6. Lifecycle and cleanup¶
Every VM follows a five-state lifecycle:
Destructed → Constructing → Constructed → Destructing → Destructed, plus the
terminal Disposed.
import { ConstructionStatus } from "@thekaveh/vmx";
console.log(userVM.status); // ConstructionStatus.Constructed
// reconstruct() is destruct() + construct() in a single call. It is only valid
// from Constructed (canReconstruct() is true iff status === Constructed); it
// round-trips through Destructed and back to Constructed.
userVM.reconstruct();
console.log(userVM.status); // ConstructionStatus.Constructed
// destruct() transitions back to Destructed and runs onDestruct.
userVM.destruct();
console.log(userVM.status); // ConstructionStatus.Destructed
// dispose() is terminal and idempotent. Calling construct() or destruct()
// on a disposed VM raises StatusTransitionError.
userVM.dispose();
console.log(userVM.status); // ConstructionStatus.Disposed
// CompositeVM.dispose() disposes children, then itself.
tabs.dispose();
// MessageHub.dispose() completes the underlying Rx Subject.
hub.dispose();
See Lifecycle & Messaging for the full lifecycle contract (
LIFE-001..015), including the transition table and admitted-hook/disposal coordination.
3.4.7. Threading¶
RxDispatcher pairs two RxJS schedulers:
| Scheduler | Typical mapping |
|---|---|
dispatcher.foreground |
UI thread / animationFrameScheduler |
dispatcher.background |
asyncScheduler / worker threads |
All hub observations delivered on foreground are safe to bind to UI controls.
Use observeOn from rxjs/operators to marshal:
import { filter, observeOn } from "rxjs/operators";
import { PropertyChangedMessage } from "@thekaveh/vmx";
hub.messages.pipe(
filter((m): m is PropertyChangedMessage<unknown> => m instanceof PropertyChangedMessage),
observeOn(dispatcher.foreground), // marshal to UI scheduler
).subscribe((msg) => updateLabel(msg));
See Services, Messages & Dispatching for the THR-001..THR-004 conformance rules.
3.4.8. Test viewmodels without runner-specific fixtures¶
The @thekaveh/vmx/testing subpath supplies hermetic services and semantic
recorders without importing Vitest or Jest:
import { createTestServices, recordPropertyChanges } from "@thekaveh/vmx/testing";
const services = createTestServices();
const changes = recordPropertyChanges(services.hub, {
sender: userVM,
propertyName: "model",
});
userVM.model = { name: "Updated", email: "updated@example.com" };
expect(changes.records).toHaveLength(1);
changes.dispose();
services.dispose();
Use ManualDispatcher when foreground and background work must remain queued
until the test explicitly flushes it. CommandDouble, CommandDoubleOf<T>,
and AsyncCommandDouble expose execution records and controlled admission,
fault, completion, and cancellation. createFormHarness() exercises a real
FormVM through set/approve/deny and validation or persistence failures.
These are supported TypeScript-package APIs, isolated from the root runtime entry and governed by its SemVer. The source-ready subpath becomes installable from npm only after the first TypeScript publication in issue #57.
3.4.9. Observe a hub safely¶
Use the optional DevTools subpath when development diagnostics need a message log or an explicit state snapshot:
import { connectReduxDevtools } from "@thekaveh/vmx/devtools";
const devtools = connectReduxDevtools(hub, {
name: "user-editor",
snapshots: [
{
name: "user",
select: () => ({ name: userVM.model.name }),
},
],
throttleMs: 16,
onError: (error, context) => console.warn(context.phase, error),
});
// The owning adapter controls this lifetime.
devtools.dispose();
VMx does not serialize the message sender or model graph by default. Add only
the named snapshots needed for diagnosis, and use redact or a snapshot
serialize function before values cross the transport. Sender names and scalar
metadata can still contain identifiers, so redact actions too when required.
allow, deny,
sampleEvery, and throttleMs bound high-volume streams. Without an extension,
connectReduxDevtools() returns a no-op without subscribing to the hub.
This is an observability API only. Incoming Redux DevTools commands are ignored, and VMx does not promise replay, time travel, or state reconstruction.
3.4.10. Where to go next¶
| Resource | Documentation page |
|---|---|
| Specification status | Specification & Conformance |
| Lifecycle contract | Lifecycle & Messaging |
| Messages & threading | Services, Messages & Dispatching |
| Commands | Command Families |
| Component contract | Component Family |
| Composite contract | Composite Family |
| Builders & tree | Builders, Collections & Tree Utilities |
| Architecture | Architecture Map |
| TypeScript status | TypeScript Flavor |
| Examples | Smaller Examples |