Skip to content

6.2.8.2. FormVM

6.2.8.2.1. When To Use It

Use FormVM<TM> when the user edits a working copy, then either approves the changes or denies them and reverts to the last snapshot. This is the right primitive for editors, settings forms, CRUD dialogs, and other explicit edit-save-cancel workflows.

Forms Dialogs And Notifications Flow

HTML · SVG · PNG

6.2.8.2.2. Shape And Ownership

FormVM owns a live Model, a Snapshot, dirty tracking, validation state, and approve/deny commands. It is not a leaf container and it is not a subclass of ComponentVM; consumers typically compose it inside another VM that exposes the host-specific editor surface.

Important members:

  • Model plus SetModel(...)
  • Snapshot
  • IsDirty
  • Errors, IsValid, FieldError(...)
  • ApproveCommand, ApproveAsync(), ApproveErrors
  • DenyCommand
  • optional builder resetOnApproved / ResetOnApproved / reset_on_approved

Validation keys are unrestricted field names: every string, including the empty string and JavaScript prototype names such as __proto__, constructor, or toString, is an ordinary key. A field without an error reads as absent (undefined, null, None, or nil), and Errors returns a fresh snapshot whose keys are all own entries.

6.2.8.2.3. Lifecycle And Messaging

Construction captures the initial snapshot. After that:

  • real model mutations recompute IsDirty
  • validation re-runs on construct, set-model, and deny
  • deny restores from the snapshot
  • approve persists, advances the snapshot, and publishes OnApproved
  • when configured, reset-after-approve derives a new pristine model from the captured persisted value before OnApproved fires
  • fire-and-forget approve failures surface on ApproveErrors

Field validators run first. Model validators then run in registration order and can add, replace, or clear field errors: a null/none/nil value removes an entry, and an omitted field keeps it. In Rust, the string-map with_model_validator / model_validator forms can only add or replace; use with_clearing_model_validator / clearing_model_validator, which return BTreeMap<String, Option<String>>, to clear.

Repeated disposal completes the owned channels and commands at most once while preserving the form's inert post-dispose behavior. See the Disposal Contract.

Teardown is exception-resilient: a raising completion observer or owned disposable does not stop the remaining channels and commands from completing and disposing. Throwing flavors rethrow the first failure after every step has run; rxjs reports observer errors asynchronously, and Combine observers cannot throw. In Python, a teardown deferred until an in-progress set-model or deny finishes never replaces an error that mutation is already raising.

SetModel / set_model that begins after disposal is also a complete no-op. It returns before null or equality work, leaves the live model and snapshot unchanged, does not re-run validators, and cannot change errors, dirty/valid state, approve command state, or notification channels. A set admitted before disposal keeps the normal mutation contract. Upstream async work should still be cancelled for resource control; this guard only rejects a late form result.

Strict mode gates approve-command eligibility on IsValid && IsDirty. The direct awaitable approve path in all five flavors gates on validity (and disposal) only, so a strict, valid, clean form may still be persisted explicitly. The completed Rust parity ledger records the focused Rust evidence.

6.2.8.2.3.1. Settled model publication

An accepted unequal SetModel / set_model call is one synchronous edit transaction. VMx installs the candidate, recomputes validation, errors, dirty state, and approve-command state, then publishes exactly one model PropertyChangedMessage on the configured hub. Publication is last, so a synchronous hub observer sees the complete settled form state. The property name follows the flavor idiom: "Model" in C# and "model" in Python, TypeScript, Swift, and Rust.

The equality check uses the same configured or idiomatic equality as dirty tracking. An equal candidate is a complete no-op: VMx retains the current model and does not validate, invalidate commands, or publish. A re-entrant observer may assign another unequal value; each accepted call settles and publishes once before returning.

DenyCommand keeps its explicit ordered pair: one FormRevertedMessage, then one idiomatic model property message after the revert settles. A successful reset-after-approve publishes through OnApproved only and does not emit a model property message.

6.2.8.2.3.2. Declarative submit-then-clear

Configure reset-after-approve when a successful submission should leave the form pristine with a derived next model. VMx captures the model before awaiting persistence, persists it, checks disposal, invokes the reset once, snapshots the reset result twice for independent live and snapshot values, revalidates, and commits the transition. OnApproved then emits the captured persisted model while observers see the already-reset form.

The reset wins over a SetModel racing the persistence wait. It does not run for invalid approval, persistence failure or cancellation, disposal during persistence, or deny/revert. If the reset or snapshot preparation fails, persistence has already succeeded but local state is not changed and OnApproved does not fire. Awaitable approval throws that failure; the command path emits it once on ApproveErrors. A retry can therefore repeat external persistence and should be handled as such.

6.2.8.2.4. Cross-Language Surface

Concept C# Python TypeScript Swift Rust
Type FormVM<TM> FormVM[TM] FormVM<TM> FormVM<Model> FormVm<M>
Builder FormVMBuilder<TM> FormVMBuilder[TM] FormVM.builder<TM>() builder surface FormVm::builder()
Mutator SetModel(...) set_model(...) setModel(...) setModel(...) set_model(...)
Awaitable/direct approve ApproveAsync() approve_async() approveAsync() approveAsync() approve()
Reset builder ResetOnApproved reset_on_approved resetOnApproved resetOnApproved reset_on_approved

Builder examples use the idiomatic name but the same captured-model contract:

var form = FormVM<OrderDraft>.Builder()
    .Initial(initial)
    .Persister(SaveAsync)
    .ResetOnApproved(saved => OrderDraft.Empty(saved.CustomerId))
    .Build();
form = (FormVM.builder()
    .initial(initial)
    .persister(save)
    .reset_on_approved(lambda saved: OrderDraft.empty(saved.customer_id))
    .build())
const form = FormVM.builder<OrderDraft>()
  .initial(initial)
  .persister(save)
  .resetOnApproved((saved) => OrderDraft.empty(saved.customerId))
  .build();
let form = try FormVM<OrderDraft>.builder()
    .initial(initial)
    .persister(save)
    .resetOnApproved { saved in .empty(customerID: saved.customerID) }
    .build()
let form = FormVm::builder()
    .initial(initial)
    .persister(save)
    .reset_on_approved(|saved| Ok(OrderDraft::empty(saved.customer_id)))
    .build()?;

6.2.8.2.5. Example

The Notes Workspace editor is the best concrete reference:

  • C#: NoteFormVM.cs
  • Python: note_form_vm.py
  • TypeScript: noteFormVM.ts
  • Swift: NoteFormVM.swift

All four compose a strict inner FormVM rather than subclassing it, then layer editor-specific commands and notifications around that core workflow.

_form = new FormVM<NoteModel>(
    initial: note,
    persister: PersistAsync,
    hub: Hub,
    strict: true,
    validators: new Dictionary<string, Func<NoteModel, string?>>
    {
        [nameof(Title)] = note => string.IsNullOrWhiteSpace(note.Title) ? TitleRequired : null
    });
form = FormVM(
    initial=note,
    persister=self._persist,
    hub=self._hub,
    strict=True,
    validators={"title": lambda m: _TITLE_REQUIRED if not m.title.strip() else None},
)
this.#form = new FormVM<NoteModel>({
  initial: note,
  persister: (m) => this.#persistAsync(m),
  hub: this._hub,
  strict: true,
  validators: {
    title: (m) => m.title.trim().length === 0 ? TITLE_REQUIRED : null,
  },
});
let form = FormVM<NoteModel>(
    initial: note,
    persister: { [weak self] n in
        guard let self else { return }
        try await self._repo.saveNote(n)
    },
    hub: hub,
    strict: true,
    validators: [
        "title": { model in
            model.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
                ? Self.titleRequired
                : nil
        }
    ]
)

6.2.8.2.6. TypeScript Cloneability And Tableau Migration

TypeScript uses structuredClone for the default snapshot. Functions, WeakMap/WeakSet, host handles, and objects containing those values are not structured-cloneable. If the default fails during construction, approve snapshot advance, or deny/revert, FormVM reports that phase and the first failing enumerable top-level field it can safely identify. The native error is preserved as cause; the diagnostic never renders field values.

The matching default structural equality understands the same common binary values: buffers compare by concrete buffer type, length, and bytes; DataView and typed-array views compare by concrete view type and their visible byte span. Separately allocated equal bytes therefore remain clean, while changed bytes or a different binary interpretation make the form dirty. Inject equals when a domain needs reference identity or a different binary policy.

A model and its default snapshot compare clean for every value the default comparator supports: primitives (NaN equals NaN, -0 equals 0), BigInt, Date by instant (an invalid Date equals another invalid Date), RegExp by source and flags, the binary values above, arrays, plain objects by own enumerable keys including undefined-valued keys, and cycles. Map keys and Set members keep SameValueZero membership, so object keys, which structuredClone copies, make a fresh form dirty; inject equals to compare them structurally.

Error values compare by what structuredClone keeps (ADR-0135). Two errors are equal when they have the same kind, the same own message, and equal own cause values, compared recursively; a cycle through cause terminates. The kind is the standard error named by name (Error, EvalError, RangeError, ReferenceError, SyntaxError, TypeError, or URIError); any other name, such as a custom subclass or AggregateError, clones and compares as a plain Error. The stack, custom properties such as a code, an AggregateError's errors, and subclass identity are not kept by the clone, so they are not compared: a model holding a custom error starts clean, and changing only its code does not make the form dirty. Inject a matching snapshotter and equals when a domain needs those details. Node 20's structuredClone throws for an error whose cause chain leads back to itself, so on Node 20 a model holding one needs an injected snapshotter; Node 22 and later clone it.

Field localization inspects data-property descriptors, performs no writes, and does not invoke getters a second time. User-defined accessors or proxy traps can have side effects during the original structuredClone call or descriptor inspection, so FormVM deliberately omits the field name when localization cannot be guaranteed.

Tableau's genesis form carries an opaque imagePayload beside ordinary form data. Keep the opaque value by reference, explicitly define which plain fields participate in dirty tracking, and configure both hooks together:

const snapshotGenesis = (model: GenesisModel): GenesisModel => ({
  ...structuredClone({ prompt: model.prompt, seed: model.seed }),
  imagePayload: model.imagePayload,
});

const equalsGenesis = (a: GenesisModel, b: GenesisModel): boolean =>
  a.prompt === b.prompt && a.seed === b.seed;

const form = FormVM.builder<GenesisModel>()
  .initial(initial)
  .persister(persistGenesis)
  .snapshotter(snapshotGenesis)
  .equals(equalsGenesis)
  .resetOnApproved((approved) => ({
    ...approved,
    imagePayload: undefined,
  }))
  .build();

Here imagePayload is intentionally excluded from dirty tracking, restored by reference on deny, and cleared only after its captured value has been successfully persisted. This replaces a persister closure that captured the not-yet-created form merely to call setModel at the end. If reference identity should count as dirty, include a.imagePayload === b.imagePayload in equalsGenesis. VMx does not provide snapshotExclude: exclusion without an explicit equality policy would make dirty and revert semantics ambiguous.

6.2.8.2.7. Common Pitfalls

  • Treating FormVM as a drop-in ComponentVM subclass. It is a distinct workflow primitive.
  • Relying on shallow copy semantics for nested mutable models. Inject a custom snapshotter and matching equality predicate where the default is not appropriate.
  • Ignoring ApproveErrors on fire-and-forget command paths.
  • Retrying blindly after reset failure; the external persist already succeeded.
  • Re-implementing save/cancel/dirty plumbing in every editor instead of composing the primitive once.
  • Keeping a form-specific “zombie assignment” flag solely to stop late setModel calls. VMx now rejects those calls after disposal; retain only cancellation that still owns application resources.