6.2.2. Component Family¶
6.2.2.1. When To Use It¶
Use ComponentVM for any addressable leaf VM that is not itself a container.
This is the default choice for note rows, notebook rows, status panels,
capability bars, and other single-node surfaces.
Reach for the modeled variant when the VM owns a domain payload, and the readonly variant when the payload should be fixed after construction.
6.2.2.2. Shape And Ownership¶
The component family has three shipped variants:
| Variant | Owns model | Model mutable | Typical use |
|---|---|---|---|
ComponentVM |
No | n/a | simple leaf state |
ComponentVM<M> / ComponentVMOf<M> |
Yes | Yes | editable or refreshable leaf |
ReadonlyComponentVM<M> / ReadonlyComponentVMOf<M> |
Yes | No | immutable projection |
All variants share the same lifecycle base, built-in selection commands, and
per-instance property-changed surface. They also carry the internal Parent
back-reference used by selection predicates, but they never own children.
The injected hub is publicly readable on every component (Hub in C# and
hub elsewhere) but remains application-owned shared infrastructure. Disposing
a VM never disposes that hub.
6.2.2.3. Lifecycle And Messaging¶
Construction is leaf-local: there is no child orchestration. The family still publishes the same status transitions and property-changed messages as every other VM.
Important behavior from the spec:
Modelchanges publish only on real value change.ModeledHintrecomputes only when the model actually changes.- Explicit model republish announces the retained model without assignment, equality, hinter, callback, or hint-state work.
- A model assignment that begins after disposal is inert before equality, retained-state mutation, hint recomputation, callbacks, or either notification channel. The previous model and hint remain readable.
SelectNextCommandandSelectPreviousCommandexist for uniform surface, but the base leaf implementation is inert.can_select()depends onParent, current-selection state, and the leaf being constructed.
6.2.2.4. Cross-Language Surface¶
| Concept | C# | Python | TypeScript | Swift | Rust |
|---|---|---|---|---|---|
| Unmodeled leaf | ComponentVM |
ComponentVM |
ComponentVM |
ComponentVM |
ComponentVm |
| Modeled leaf | ComponentVM<M> |
ComponentVMOf[M] |
ComponentVMOf<M> |
ComponentVMOf<M> |
ComponentVm<M> |
| Readonly leaf | ReadonlyComponentVM<M> |
ReadonlyComponentVMOf[M] |
ReadonlyComponentVMOf<M> |
ReadonlyComponentVMOf<M> |
ReadonlyComponentVm<M> |
| Builder entry | Builder() |
builder() |
builder() |
builder() |
with_model(...) |
| Property channel | INotifyPropertyChanged |
property_changed |
propertyChanged |
propertyChanged |
property_changed() |
| Shared hub | Hub |
hub |
hub |
hub |
hub() |
| Own until dispose | Own(...) |
_own(...) |
own(...) |
own(...) |
own(...) |
| Republish model | RepublishModel() |
republish_model() |
republishModel() |
republishModel() |
republish_model() |
6.2.2.5. Explicitly Republishing The Retained Model¶
Use explicit republish when observable state reachable through the retained
model changed outside ordinary model replacement and existing adapters
intentionally refresh from the model property notification. The operation is
available on writable and read-only modeled leaves and delegates through
ForwardingComponentVM to the wrapped VM.
One call preserves the exact model reference/value and observable modeled-hint
value. Republish itself does not compare or assign the model, invoke the modeled
hinter, or run OnModelChanged / onModelChanged. It requests one ordinary
model notification through the existing dual-channel helper: one
PropertyChangedMessage on the hub followed by one local property notification
for a top-level call. Property names remain idiomatic: "Model" in C# and
"model" in Python, TypeScript, Swift, and Rust.
// heightFields is external renderer state reachable through this cell VM.
heightFields.set(cell.coordKey, heightField);
cell.republishModel();
A null/default hub remains safe and the local channel still emits. Calls that begin after disposal are inert. Re-entrant calls use the hub's existing lossless queue, so each admitted call contributes one complete pair without recursive delivery or message loss.
Read-only means VMx cannot replace the retained model; it does not make a referenced object deeply immutable. Republish therefore remains available on a read-only modeled leaf without exposing a setter or requesting hint recomputation. Forwarding preserves the wrapped sender, hub, local stream, and disposal boundary.
Do not use republish to conceal an ordinary model replacement or mutation that
belongs in the equality-gated assignment path. FormVM does not expose this
operation: its model publication is a distinct validation, dirty-state, and
command transaction.
6.2.2.6. Owning Long-Lived Resources¶
Subclass authors can register subscriptions and cleanup callbacks that should live until terminal VM disposal. The registry drains once in LIFO order after the subclass disposal hook; one cleanup failure is isolated, and registration after disposal cleans immediately. Reconstruct does not drain the registry.
class SearchVM extends ComponentVMBase {
constructor(hub: IMessageHub, dispatcher: IDispatcher) {
super({ name: "search", hint: "", hub, dispatcher });
this.own(hub.messages.subscribe(message => this.receive(message)));
}
}
Use OnConstruct/OnDestruct (or their flavor equivalents) for resources that
must be replaced on every reconstruct. The helper deliberately does not expose
two mutable lifetime bags.
The NNx Studio pilot removed 16 inherited hub getters, two subscription fields,
two manual disposal overrides, and its two-case VMx hub-getter regression test
(10 additions, 104 deletions). Its viewmodel package remained type-clean and
all 319 remaining tests passed. The application-owned WorkspaceVM hub getter
remained because that class does not derive from a VMx component base.
6.2.2.7. Authoring A Mutable Property¶
A component property has two audiences: cross-VM coordination through the message hub and host binding through the VM's local property-change surface. The subclass helper keeps those channels paired and ordered.
| Flavor | Subclass helper |
|---|---|
| C# | NotifyPropertyChanged |
| Python | _notify_property_changed |
| TypeScript | _notifyPropertyChanged |
| Swift | _notifyPropertyChanged |
| Rust | notify_property_changed |
The caller first determines that a change was accepted. A setter still owns its
equality check and assignment; a computed refresh runs only after its
underlying state changes. One helper call invokes exactly one hub
PropertyChangedMessage send, then exactly one local notification carrying the
current value. A call begun after disposal is inert, while a call admitted
before disposal completes both channels even if a hub observer disposes the VM.
Property names follow the flavor idiom (ActiveTab, active_tab, or
activeTab). The lower-level local-only raise primitive stays available for
framework lifecycle properties that deliberately do not publish to the hub.
Ordinary top-level delivery is observed hub first. Inside a hub transaction or re-entrant drain, the hub queues its delivery, so a local observer may run before the queued hub observer even though the helper invoked the hub first.
set activeTab(value: string) {
if (this._activeTab === value) return;
this._activeTab = value;
this._notifyPropertyChanged("activeTab");
}
Do not wrap fields with a VMx property decorator or descriptor. Explicit setters preserve each flavor's idioms and make equality, assignment, and notification order visible at the mutation site.
This pattern was piloted against NNx Studio's ConsoleVM: two hand-written
hub-plus-local emission sequences collapsed to the helper call, the viewmodel
package remained type-clean, and its focused test suite passed all 7 tests,
including an assertion that the hub event precedes the local event.
VMx's C#, Python, TypeScript, and Swift Notes Showcase viewmodels use the same helper, eliminating their former hand-written hub-plus-local pairs.
The built-in modeled setter applies the disposal admission guard before its own equality check. Forwarding components and Swift's internal read-only update path inherit that guard. A modeled composite is different: its model configures a child factory and is not a settable retained property.
6.2.2.8. Example¶
Representative modeled leaf shape across the five source flavors:
C#:ComponentVM<TabModel>.Builder().Name("home-tab").Model(model).Services(hub, dispatcher).Build()Python:ComponentVMOf.builder().name("home-tab").model(model).services(hub, dispatcher).build()TypeScript:ComponentVMOf.builder<TabModel>().name("home-tab").model(model).services(hub, dispatcher).build()Swift:try ComponentVMOf<TabModel>.builder().name("home-tab").model(model).services(hub: hub, dispatcher: dispatcher).build()Rust:ComponentVm::with_model("home-tab", model, hub, dispatcher)
The Quickstart page uses exactly this pattern before composing those leaves into
a CompositeVM.
6.2.2.9. Common Pitfalls¶
- Using a component when the VM really owns a collection. Move up to
CompositeVM,GroupVM, orAggregateVM. - Expecting the built-in next/previous commands on a leaf to walk siblings. Container-driven navigation is the intended model.
- Subclassing to add domain behavior instead of composing around the sealed VM.
- Forgetting that a detached leaf has no
Parent, so selection predicates stay false until a container owns it. - Disposing
vm.hubfrom the VM. Public visibility does not transfer ownership. - Registering per-construct work with
own; it would survive reconstruct. - Treating the post-disposal model guard as task cancellation. Cancel upstream work to release resources; rely on the guard only to reject a late result.
- Calling model republish after an ordinary replacement instead of assigning the new model through the normal equality-gated setter.