ViewModels
Deep dive into the ViewModel layer — BaseViewModel, Commands, RestfulApiViewModel, FormViewModel, QueryableCollectionViewModel, and real-world implementations from the Web Loom example apps.
ViewModels
The ViewModel sits between the Model and the View. It reads Model signals, derives presentation state with computed(), and exposes Commands that the View calls on user interaction. ViewModels contain no framework imports — they are plain TypeScript and can be unit-tested without a DOM.
@web-loom/mvvm-core ships ViewModel base classes covering the most common UI patterns, built on @web-loom/signals-core.
ViewModel Class Overview
BaseViewModel<TModel>
└── RestfulApiViewModel<TData, TSchema> — CRUD commands for REST resources
FormViewModel<TData>andQueryableCollectionViewModel<T>also exist inmvvm-core's source, but aren't exported from its public entry point yet — see the note near the bottom of this page before relying on them.
BaseViewModel
BaseViewModel<TModel> is the root class for all domain ViewModels. It accepts a Model, re-exposes its signals, manages subscriptions, and disposes everything cleanly when the ViewModel is destroyed.
Constructor
import { BaseViewModel } from '@web-loom/mvvm-core';
import { UserModel } from './models/UserModel';
class UserProfileViewModel extends BaseViewModel<UserModel> {
constructor(private readonly model: UserModel) {
super(model);
}
}
const vm = new UserProfileViewModel(new UserModel());Exposed Signals
BaseViewModel re-exposes the injected model's core signals (the same ReadonlySignal instances, not copies):
data$— the model's current dataisLoading$—truewhile a fetch or mutation is in flighterror$— the last error from the model, ornullvalidationErrors$— acomputed()fromerror$; resolves to aZodErrorwhen the model receives invalid API data,nullotherwise
These let the View read directly from the ViewModel without reaching into the Model.
Subscription Lifecycle
Because data$/isLoading$/error$ are the same signal instances the Model owns, there's nothing to unsubscribe on them specifically when the ViewModel disposes. Derived values built with computed() need no manual cleanup either — they're just functions of their inputs. What does need lifecycle tracking is any subscription you set up by hand (sig.subscribe(fn) / observe(sig, fn)) — register its returned unsubscribe function with addSubscription():
class DashboardViewModel extends BaseViewModel<MetricsModel> {
readonly summary$ = computed(() => computeSummary(this.model.data$.get()));
constructor(model: MetricsModel) {
super(model);
// Only needed for side effects that aren't plain derived state
this.addSubscription(
this.model.data$.subscribe((data) => this.logMetricsChange(data)),
);
}
}computed() values like summary$ above need no addSubscription() call — only manual .subscribe()/observe() calls do.
Registering Commands
Commands registered with registerCommand() are automatically disposed when the ViewModel is disposed — you don't need to track them manually:
class TaskViewModel extends BaseViewModel<TaskModel> {
readonly fetchCommand = this.registerCommand(
new Command(async () => {
await this.model.fetch();
})
);
}Disposal
vm.dispose();- Calls
dispose()on every registered Command - Runs every teardown added via
addSubscription()
Note that BaseViewModel.dispose() does not dispose the injected Model — the ViewModel doesn't own the Model's lifecycle (it was injected, not constructed internally; see Don't: Create Models Inside the ViewModel). If a Model needs disposal, dispose it wherever it was constructed.
Always trigger disposal in the component teardown hook:
// React
useEffect(() => {
vm.fetchCommand.execute();
return () => vm.dispose();
}, []);
// Vue
onUnmounted(() => vm.dispose());
// Angular
ngOnDestroy() { this.vm.dispose(); }Command
Command<TParam, TResult> is the primary mechanism for user actions in Web Loom. It wraps an async operation and manages its own execution state reactively.
Signals
isExecuting$—truewhile the async function is running; use for spinners and button statescanExecute$—truewhen the command is allowed to run; bind todisabledon buttons. It's acomputed(), so it re-evaluates automatically whenever a signal read inside a guard changesexecuteError$— the last error thrown by the execute function, ornull
Basic Usage
import { Command } from '@web-loom/mvvm-core';
class SaveViewModel extends BaseViewModel<DocumentModel> {
readonly saveCommand = this.registerCommand(
new Command(async () => {
await this.model.save(this.form.values);
})
);
}In the View (React):
<button
onClick={() => vm.saveCommand.execute()}
disabled={!vm.saveCommand.canExecute}
>
{vm.saveCommand.isExecuting ? 'Saving…' : 'Save'}
</button>canExecute Conditions
Pass a signal (or a () => boolean function) that reads false to disable the command while a condition isn't met:
// Disable while already loading
readonly fetchCommand = this.registerCommand(
new Command(
async () => { await this.model.fetch(); },
computed(() => !this.model.isLoading$.get()),
)
);Combine multiple conditions after construction with fluent methods:
readonly submitCommand = this.registerCommand(
new Command(async () => { await this.submit(); })
.observesCanExecute(this.form.isValid$) // must be valid
.observesProperty(computed(() => !this.model.isLoading$.get())) // must not be loading
);.observesCanExecute(sig)— adds a boolean condition; all conditions are AND-ed.observesProperty(sig)— checks that the signal currently reads a truthy value.raiseCanExecuteChanged()— manually forcecanExecute$to re-evaluate, for guards that read plain (non-signal) state
Commands with Parameters
readonly deleteCommand = this.registerCommand(
new Command<string>(async (id) => {
await this.model.delete(id);
})
);
// Call from View:
vm.deleteCommand.execute(item.id);Fluent Builder
Use Command.create() for more explicit typing, especially with complex payload shapes:
import { Command } from '@web-loom/mvvm-core';
readonly signInCommand = Command.create<SignInPayload, AuthTokenResponse>()
.withExecute(async (payload) => {
const result = await this.model.signIn(payload);
this._sessionResult.set(result);
return result;
})
.build();RestfulApiViewModel
RestfulApiViewModel<TData, TSchema> wraps a RestfulApiModel and exposes pre-built CRUD commands. For most REST resources, you extend this class rather than BaseViewModel directly.
Built-in Commands
fetchCommand— executesmodel.fetch(id?). Pass a string ID or array of IDs, or nothing for the full collection.createCommand— executesmodel.create(payload)with an optimistic add.updateCommand— executesmodel.update(id, payload)with an optimistic patch. Payload shape:{ id: string; payload: Partial<Item> }.deleteCommand— executesmodel.delete(id)with an optimistic remove.
Item Selection
RestfulApiViewModel also maintains a selection state for list-detail patterns:
selectedItem$— acomputed()signal for the currently selected item, derived fromdata$and an internal selected-id signalselectItem(id | null)— update the selection
vm.selectItem('42'); // select item with id '42'
vm.selectItem(null); // clear selection
// Subscribe in the View
vm.selectedItem$.subscribe(item => renderDetailPanel(item));Extending RestfulApiViewModel
For simple CRUD resources, the class body can be nearly empty:
import { RestfulApiViewModel } from '@web-loom/mvvm-core';
import { SensorModel } from '@repo/models';
import { SensorListData, SensorListSchema } from '@repo/schemas';
export class SensorViewModel extends RestfulApiViewModel<
SensorListData,
typeof SensorListSchema
> {
constructor(model: SensorModel) {
super(model);
}
}
// All CRUD commands are inherited
vm.fetchCommand.execute();
vm.createCommand.execute({ type: 'temperature', status: 'active', greenhouseId: 1 });
vm.deleteCommand.execute('sensor-uuid');Adding Domain Commands
Extend the class to add business-specific actions on top of the inherited CRUD:
// From packages/mvvm-core/src/examples/viewmodels/RestfulTodoViewModel.ts
export class RestfulTodoViewModel extends RestfulApiViewModel<
RestfulTodoListData,
typeof RestfulTodoListSchema
> {
// Domain-specific add command with reduced payload (no id, no timestamps)
readonly addTodoCommand = this.registerCommand(
new Command<Pick<RestfulTodoData, 'text' | 'isCompleted'>>(
async (payload) => {
await this.createCommand.execute(payload);
}
)
);
// Toggle as a named action — clearer intent than calling updateCommand directly
async toggleTodoCompletion(id: string) {
const todo = this.getCurrentItemById(id);
if (!todo) return;
await this.updateCommand.execute({
id,
payload: { isCompleted: !todo.isCompleted },
});
}
}FormViewModel and QueryableCollectionViewModel (internal, not yet exported)
mvvm-core's source includes two additional ViewModel classes that aren't exported from the package's public entry point (src/index.ts) — they're internal/unstable, and code that tries to import { FormViewModel } from '@web-loom/mvvm-core' today will not compile:
FormViewModel<TData>— would manage form field state, dirty/valid tracking (debounced Zod validation), field-level errors, and asubmitCommand, independent of any ModelQueryableCollectionViewModel<T>— would layer client-side text filtering (debounced viadebouncedSignal), multi-key sorting, and pagination on top of a list signal, without talking to an API
Until these are exported:
- For form handling, use
@web-loom/forms-corewith its React/Vue/vanilla adapters. - For filter/sort/pagination, derive the state yourself with
computed()and adebouncedSignal()over anObservableCollection'sitems$, or a plainsignal()holding the current page/sort/filter parameters.
Real-World Example: AuthViewModel
From packages/view-models/src/AuthViewModel.ts — a full authentication ViewModel built on BaseViewModel:
import { signal, observe, type ReadonlySignal } from '@web-loom/signals-core';
import { BaseViewModel, Command, type ICommand } from '@web-loom/mvvm-core';
import { AuthModel, type AuthTokenResponseData, type SignInPayload, type SignUpPayload, type UserData } from '@repo/models';
export class AuthViewModel extends BaseViewModel<AuthModel> {
private readonly _sessionResult = signal<AuthTokenResponseData | null>(null);
readonly sessionResult$: ReadonlySignal<AuthTokenResponseData | null> = this._sessionResult.asReadonly();
readonly token$: ReadonlySignal<string | null>;
private readonly _authenticated = signal<boolean>(false);
readonly isAuthenticated$: ReadonlySignal<boolean> = this._authenticated.asReadonly();
readonly user$: ReadonlySignal<UserData | null>;
readonly signInCommand: ICommand<SignInPayload, AuthTokenResponseData>;
readonly signUpCommand: ICommand<SignUpPayload, AuthTokenResponseData>;
readonly signOutCommand: ICommand<void, void>;
constructor(model: AuthModel) {
super(model);
this.user$ = this.data$ as ReadonlySignal<UserData | null>;
this.token$ = model.token$;
// observe() delivers the stored token immediately, then on every change —
// isAuthenticated$ needs an initial value, not just future transitions
const unsubscribe = observe(this.token$, (token) => {
this._authenticated.set(Boolean(token));
});
this.addSubscription(unsubscribe);
this.signInCommand = Command.create<SignInPayload, AuthTokenResponseData>()
.withExecute(async (payload) => {
const result = await model.signIn(payload);
this._sessionResult.set(result);
return result;
})
.build();
this.signUpCommand = Command.create<SignUpPayload, AuthTokenResponseData>()
.withExecute(async (payload) => {
const result = await model.signUp(payload);
this._sessionResult.set(result);
return result;
})
.build();
this.signOutCommand = Command.create<void, void>()
.withExecute(async () => {
await model.signOut();
this._sessionResult.set(null);
})
.build();
}
dispose(): void {
super.dispose();
this.signInCommand.dispose();
this.signUpCommand.dispose();
this.signOutCommand.dispose();
}
}Key patterns shown here:
- Commands typed with
Command.create<TParam, TResult>()for explicit payload and return types isAuthenticated$isn't acomputed()oftoken$— it's a separate signal kept in sync viaobserve(), because it needs an immediate initial value derived from the token at construction time, not just future changes- A local
_sessionResultsignal for state that lives in the ViewModel, not the Model signInCommand/signUpCommand/signOutCommandare plain fields (not wrapped inregisterCommand()), sodispose()disposes each one explicitly —super.dispose()doesn't know about them
Testing ViewModels
ViewModels have no framework imports, so they test as plain TypeScript with Vitest.
Testing Commands
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { TaskViewModel } from './TaskViewModel';
import { TaskModel } from './TaskModel';
describe('TaskViewModel', () => {
let model: TaskModel;
let vm: TaskViewModel;
beforeEach(() => {
model = new TaskModel();
vm = new TaskViewModel(model);
});
afterEach(() => vm.dispose());
it('fetchCommand calls model.fetch()', async () => {
const spy = vi.spyOn(model, 'fetch').mockResolvedValue(undefined);
await vm.fetchCommand.execute();
expect(spy).toHaveBeenCalledOnce();
});
it('fetchCommand is disabled while loading', () => {
// Simulate loading state
model['setLoading'](true);
expect(vm.fetchCommand.canExecute$.peek()).toBe(false);
});
});Testing Derived Signals
it('isAuthenticated$ is false before sign in', () => {
const vm = new AuthViewModel(new AuthModel());
expect(vm.isAuthenticated$.peek()).toBe(false);
vm.dispose();
});
it('isAuthenticated$ is true after successful sign in', async () => {
const model = new AuthModel();
vi.spyOn(model, 'signIn').mockResolvedValue({ token: 'abc' });
const vm = new AuthViewModel(model);
await vm.signInCommand.execute({ email: 'a@b.com', password: 'pass' });
expect(vm.isAuthenticated$.peek()).toBe(true);
vm.dispose();
});Testing State Transitions
Track executing state across a command execution:
it('isExecuting$ is true during command execution', async () => {
const states: boolean[] = [];
let resolve: () => void;
const pending = new Promise<void>(r => { resolve = r; });
model.fetch = vi.fn(() => pending);
const unsubscribe = vm.fetchCommand.isExecuting$.subscribe(v => states.push(v));
vm.fetchCommand.execute(); // don't await
await new Promise(r => setTimeout(r, 10));
expect(states).toContain(true); // was executing
resolve!();
await vm.fetchCommand.execute();
unsubscribe();
expect(states.at(-1)).toBe(false); // finished
});Dos and Don'ts
Do: Keep Views Dumb — Put Logic in ViewModels
// ✅ Good — ViewModel owns all logic
class TaskViewModel extends BaseViewModel<TaskModel> {
readonly hasOverdueTasks$ = computed(() =>
(this.model.data$.get() ?? []).some((t) => t.dueDate < new Date() && !t.done),
);
readonly markDoneCommand = this.registerCommand(
new Command<string>(async (id) => {
await this.model.update(id, { done: true });
})
);
}
// View just binds
<span>{vm.hasOverdueTasks ? 'Overdue!' : 'All clear'}</span>
<button onClick={() => vm.markDoneCommand.execute(task.id)}>Done</button>// ❌ Bad — View contains business logic
function TaskRow({ task, model }) {
const isOverdue = task.dueDate < new Date() && !task.done; // ← belongs in VM
const handleDone = async () => {
await model.update(task.id, { done: true }); // ← View reaches into Model directly
};
}Do: Use Commands for All Async Actions
// ✅ Good — command manages loading + error automatically
readonly saveCommand = this.registerCommand(
new Command(async () => { await this.model.save(this.form.values); })
);// ❌ Bad — ViewModel manages loading state manually, View has try/catch
async save() {
this.isLoading = true;
try {
await this.model.save(values);
} catch (e) {
this.error = e;
} finally {
this.isLoading = false;
}
}Do: Derive with computed(), Don't Duplicate State
// ✅ Good — single source, derived automatically
readonly hasSession$ = computed(() => this.model.token$.get() !== null);// ❌ Bad — two places to keep in sync
private _hasSession = false;
async signIn() {
await this.model.signIn(...);
this._hasSession = true; // ← can drift from model state
}If a derived signal genuinely needs an immediate value on construction rather than only reacting to future changes (see the real AuthViewModel.isAuthenticated$ above), use a plain signal() kept in sync via observe() instead of computed() — that's a deliberate exception, not the default.
Do: Register Commands to Get Auto-Disposal
// ✅ Good — no need to dispose manually
readonly fetchCommand = this.registerCommand(
new Command(async () => { await this.model.fetch(); })
);
dispose() {
super.dispose(); // fetchCommand is auto-disposed
}// ❌ Bad — unregistered command leaks unless manually disposed
readonly fetchCommand = new Command(async () => { await this.model.fetch(); });
dispose() {
super.dispose();
// fetchCommand.dispose() never called — its internal signals stay wired up
}super.dispose() only disposes commands registered via registerCommand() and runs teardowns added via addSubscription() — it does not know about plain fields. If you keep an unregistered Command or a signal you manage by hand (like AuthViewModel's _sessionResult), dispose/clean it up explicitly in your own dispose() override; call order relative to super.dispose() doesn't matter since the two cleanup paths are independent.
Don't: Subscribe Inside ViewModels Without addSubscription
// ❌ Bad — subscription leaks after dispose()
constructor(model: MyModel) {
super(model);
this.model.data$.subscribe(data => this.doSomething(data));
}// ✅ Good — unsubscribe function registered, invoked when the ViewModel is disposed
constructor(model: MyModel) {
super(model);
this.addSubscription(
this.model.data$.subscribe(data => this.doSomething(data)),
);
}Note this only applies to manual .subscribe()/observe() calls — computed() values need no such registration; they have no subscription to leak.
Don't: Create Models Inside the ViewModel
// ❌ Bad — tight coupling; hard to test (can't inject a mock)
class UserViewModel extends BaseViewModel<UserModel> {
constructor() {
super(new UserModel()); // ← ViewModel knows how to construct Model
}
}// ✅ Good — inject the Model; easy to inject a mock in tests
class UserViewModel extends BaseViewModel<UserModel> {
constructor(model: UserModel) {
super(model);
}
}
// Test
const vm = new UserViewModel(new MockUserModel());Don't: Put UI-Only State in the Model
// ❌ Bad — the selected row index is not business data
class TaskModel extends BaseModel<Task[], ...> {
selectedIndex = 0; // ← belongs in ViewModel or Store
}// ✅ Good — selection lives in ViewModel (or Store if cross-component)
class TaskViewModel extends BaseViewModel<TaskModel> {
private readonly _selectedId = signal<string | null>(null);
readonly selectedTask$ = computed(() =>
this.model.data$.get()?.find((t) => t.id === this._selectedId.get()) ?? null,
);
select(id: string) { this._selectedId.set(id); }
}Choosing the Right ViewModel
BaseViewModel— domain ViewModels with custom logic; use whenRestfulApiViewModel's CRUD commands don't match your API shapeRestfulApiViewModel— any standard REST resource (sensors, greenhouses, users); inherit for free CRUD + selection- Need form state or client-side filter/sort/pagination? See FormViewModel and QueryableCollectionViewModel (internal, not yet exported) above for the current recommended alternative.
Where to Go Next
- Models — how Models own data and expose reactive signals
- Signals Core — the
signal/computed/effectprimitives underlying all of this - MVVM in React — wiring ViewModels into React components
- MVVM in Vue — Vue 3 Composition API integration
- MVVM in Angular — Angular DI + signal bridging