Skip to content

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 PropertyChangedMessage schema.


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 CompositeVM contract, including CollectionChangedEvent and BatchUpdate semantics.


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