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 subscribes to Model observables, derives presentation state, 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 several ViewModel base classes covering the most common UI patterns.
ViewModel Class Overview
BaseViewModel<TModel>
└── RestfulApiViewModel<TData, TSchema> — CRUD commands for REST resources
FormViewModel<TData> — form state, field validation, submit
QueryableCollectionViewModel<T> — client-side filter, sort, paginate
BaseViewModel
BaseViewModel<TModel> is the root class for all domain ViewModels. It accepts a Model, re-exposes its observables, 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 Observables
BaseViewModel re-exposes the injected model's core streams:
data$— the model's current dataisLoading$—truewhile a fetch or mutation is in flighterror$— the last error from the model, ornullvalidationErrors$— derived fromerror$; emits aZodErrorwhen the model receives invalid API data,nullotherwise
These let the View subscribe directly to the ViewModel without reaching into the Model.
Subscription Lifecycle
BaseViewModel internally uses a _destroy$ Subject and the takeUntil operator to close all subscriptions when dispose() is called:
class DashboardViewModel extends BaseViewModel<MetricsModel> {
readonly summary$ = this.model.data$.pipe(
takeUntil(this._destroy$), // auto-closed on dispose()
map(data => computeSummary(data))
);
}Any observable you derive inside a ViewModel should be piped through takeUntil(this._destroy$) to prevent memory leaks.
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();- Completes
_destroy$, closing alltakeUntil-based subscriptions - Calls
dispose()on every registered Command - Calls
dispose()on the injected Model
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.
Observables
isExecuting$—truewhile the async function is running; use for spinners and button statescanExecute$—truewhen the command is allowed to run; bind todisabledon buttonsexecuteError$— 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 an Observable that emits 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(); },
this.model.isLoading$.pipe(map(loading => !loading))
)
);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(this.model.isLoading$.pipe( // must not be loading
map(loading => !loading)
))
);.observesCanExecute(obs$)— adds a boolean condition; all conditions are AND-ed.observesProperty(obs$)— checks that the emitted value is truthy.raiseCanExecuteChanged()— manually re-evaluates all conditions
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$.next(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$— reactive observable of the currently selected item derived fromdata$and_selectedItemId$selectItem(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
FormViewModel<TData> manages form state independently of any Model. It handles field updates, real-time Zod validation, dirty tracking, and submit.
Construction
import { FormViewModel } from '@web-loom/mvvm-core';
import { z } from 'zod';
const SignUpSchema = z.object({
email: z.string().email('Must be a valid email'),
password: z.string().min(8, 'At least 8 characters'),
firstName: z.string().min(1, 'Required'),
});
const form = new FormViewModel(
{ email: '', password: '', firstName: '' }, // initial data
SignUpSchema, // Zod schema
async (data) => { await api.signUp(data); } // optional submit handler
);Observables
formData$— current form values asPartial<TData>isValid$—truewhen the current values pass the Zod schema (debounced)isDirty$—truewhen values differ from the initial stateerrors$— the fullZodError, ornullwhen validfieldErrors$—Record<keyof TData, string[] | undefined>— per-field error arrays
Mutation Methods
form.updateField('email', 'alice@example.com');
form.updateField('password', 'hunter2');
form.setFormData({ email: 'prefilled@example.com' }); // merge with initial
form.resetForm(); // revert all fields to initial valuesField-level Errors
// Get errors for one field
const emailErrors = form.getFieldErrors('email');
// → ['Must be a valid email'] or []Submit Command
// submitCommand.canExecute$ is automatically false when form is invalid
form.submitCommand.canExecute$.subscribe(enabled => setButtonEnabled(enabled));
await form.submitCommand.execute();The submit flow: validates → if invalid, sets errors$ → if valid, calls the submit handler.
FormViewModel in a ViewModel
FormViewModel is typically composed inside a domain ViewModel:
class SignUpViewModel extends BaseViewModel<AuthModel> {
readonly form = new FormViewModel(
{ email: '', password: '' },
SignUpSchema
);
readonly signUpCommand = this.registerCommand(
new Command(
async () => {
const data = this.form.formData$.getValue() as SignUpPayload;
await this.model.signUp(data);
this.form.resetForm();
}
).observesCanExecute(this.form.isValid$)
);
dispose() {
this.form.dispose();
super.dispose();
}
}QueryableCollectionViewModel
QueryableCollectionViewModel<T> provides client-side filtering, sorting, and pagination over an in-memory list. It does not talk to an API — use it in combination with a Model that loads the full dataset.
Construction
import { QueryableCollectionViewModel } from '@web-loom/mvvm-core';
const collection = new QueryableCollectionViewModel<User>(initialUsers);Loading Data
collection.loadItems(users); // replace all items
collection.addItem(newUser); // append
collection.removeItem('id', '42'); // remove where item.id === '42'
collection.updateItem('id', '42', updatedUser); // replace where item.id === '42'Filtering
// Text search across all string and number properties
collection.filterBy$.next('alice');
// Subscribe to live results
collection.paginatedItems$.subscribe(renderTable);Filtering is debounced (150ms) and resets pagination to page 1 automatically.
Sorting
collection.setSort('lastName', 'asc');
collection.setSort('createdAt', 'desc');Pagination
collection.pageSize$.next(20); // items per page
collection.goToPage(3);
collection.nextPage();
collection.prevPage();
// Read
collection.totalItems$.subscribe(n => setCount(n));
collection.totalPages$.subscribe(n => setPageCount(n));
collection.currentPage$.subscribe(p => setActivePage(p));Composed with a Model
class UserListViewModel extends BaseViewModel<UserModel> {
readonly collection = new QueryableCollectionViewModel<User>([]);
constructor(model: UserModel) {
super(model);
// Feed model data into the collection
this.model.data$.pipe(takeUntil(this._destroy$)).subscribe(users => {
this.collection.loadItems(users ?? []);
});
}
readonly fetchCommand = this.registerCommand(
new Command(async () => { await this.model.fetch(); })
);
dispose() {
this.collection.dispose();
super.dispose();
}
}The View binds to vm.collection.paginatedItems$ for the visible rows and vm.collection.filterBy$ for the search input.
Real-World Example: AuthViewModel
From packages/view-models/src/AuthViewModel.ts — a full authentication ViewModel built on BaseViewModel:
import { BaseViewModel } from '@web-loom/mvvm-core';
import { Command } from '@web-loom/mvvm-core';
import { AuthModel } from '@repo/models';
import { BehaviorSubject, map } from 'rxjs';
export class AuthViewModel extends BaseViewModel<AuthModel> {
private readonly _sessionResult$ = new BehaviorSubject<AuthTokenResponseData | null>(null);
readonly sessionResult$ = this._sessionResult$.asObservable();
// Derived from model
readonly token$ = this.model.token$;
readonly isAuthenticated$ = this.model.token$.pipe(map(t => t !== null));
readonly user$ = this.data$; // alias for model.data$
readonly signInCommand = Command.create<SignInPayload, AuthTokenResponseData>()
.withExecute(async (payload) => {
const result = await this.model.signIn(payload);
this._sessionResult$.next(result);
return result;
})
.build();
readonly signUpCommand = Command.create<SignUpPayload, AuthTokenResponseData>()
.withExecute(async (payload) => {
const result = await this.model.signUp(payload);
this._sessionResult$.next(result);
return result;
})
.build();
readonly changePasswordCommand = Command.create<ChangePasswordPayload, AuthTokenResponseData>()
.withExecute(async (payload) => {
return await this.model.changePassword(payload);
})
.build();
readonly signOutCommand = Command.create<void, void>()
.withExecute(async () => {
await this.model.signOut();
this._sessionResult$.next(null);
})
.build();
dispose() {
this._sessionResult$.complete();
this.signInCommand.dispose();
this.signUpCommand.dispose();
this.changePasswordCommand.dispose();
this.signOutCommand.dispose();
super.dispose();
}
}Key patterns shown here:
- Commands typed with
Command.create<TParam, TResult>()for explicit payload and return types - Domain-specific observables (
isAuthenticated$,token$) derived from the model - A local
_sessionResult$BehaviorSubject for state that lives in the ViewModel, not the Model - Explicit disposal of every command and subject in
dispose()
Testing ViewModels
ViewModels have no framework imports, so they test as plain TypeScript with Vitest.
Testing Commands
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { firstValueFrom } from 'rxjs';
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', async () => {
// Simulate loading state
model['setLoading'](true);
const canExecute = await firstValueFrom(vm.fetchCommand.canExecute$);
expect(canExecute).toBe(false);
});
});Testing Derived Observables
it('isAuthenticated$ is false before sign in', async () => {
const vm = new AuthViewModel(new AuthModel());
const isAuth = await firstValueFrom(vm.isAuthenticated$);
expect(isAuth).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' });
const isAuth = await firstValueFrom(vm.isAuthenticated$);
expect(isAuth).toBe(true);
vm.dispose();
});Testing FormViewModel
it('submitCommand is disabled when form is invalid', async () => {
const form = new FormViewModel({ email: '' }, SignUpSchema);
const canSubmit = await firstValueFrom(form.submitCommand.canExecute$);
expect(canSubmit).toBe(false);
form.updateField('email', 'valid@example.com');
// Wait for debounced validation
await new Promise(resolve => setTimeout(resolve, 200));
const canSubmitNow = await firstValueFrom(form.submitCommand.canExecute$);
expect(canSubmitNow).toBe(true);
form.dispose();
});Testing QueryableCollectionViewModel
it('filters items by search term', async () => {
const collection = new QueryableCollectionViewModel([
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
]);
collection.filterBy$.next('alice');
await new Promise(resolve => setTimeout(resolve, 200)); // debounce
const items = await firstValueFrom(collection.paginatedItems$);
expect(items).toHaveLength(1);
expect(items[0].name).toBe('Alice');
collection.dispose();
});Testing State Transitions
Track loading 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 sub = 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();
sub.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$ = this.model.data$.pipe(
map(tasks => tasks?.some(t => t.dueDate < new Date() && !t.done) ?? false)
);
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 Observables, Don't Duplicate State
// ✅ Good — single source, derived automatically
readonly isAuthenticated$ = this.model.token$.pipe(map(t => t !== null));// ❌ Bad — two places to keep in sync
private _isAuthenticated = false;
async signIn() {
await this.model.signIn(...);
this._isAuthenticated = true; // ← can drift from model state
}Do: Always Call super.dispose() Last
// ✅ Good — child disposes its own resources first
dispose() {
this._sessionResult$.complete(); // local subject
this.signInCommand.dispose(); // manually tracked command
this.form.dispose(); // nested FormViewModel
super.dispose(); // model + registered commands
}// ❌ Bad — super.dispose() first closes _destroy$, then pipe operators stop working
dispose() {
super.dispose(); // closes _destroy$ too early
this._sessionResult$.complete(); // already dangling
}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 never disposed → BehaviorSubjects never complete
}Don't: Subscribe Inside ViewModels Without takeUntil
// ❌ Bad — subscription leaks after dispose()
constructor() {
super(model);
this.model.data$.subscribe(data => this.doSomething(data));
}// ✅ Good — subscription closed when ViewModel is disposed
constructor() {
super(model);
this.model.data$
.pipe(takeUntil(this._destroy$))
.subscribe(data => this.doSomething(data));
}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$ = new BehaviorSubject<string | null>(null);
readonly selectedTask$ = combineLatest([this.model.data$, this._selectedId$]).pipe(
map(([tasks, id]) => tasks?.find(t => t.id === id) ?? null)
);
select(id: string) { this._selectedId$.next(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 + selectionFormViewModel— any form; compose inside a domain ViewModel alongside the submit commandQueryableCollectionViewModel— tables and lists that need client-side search, sort, and pagination
Where to Go Next
- Models — how Models own data and expose reactive observables
- Models — how Models own data and expose reactive observables
- MVVM in React — wiring ViewModels into React components
- MVVM in Vue — Vue 3 Composition API integration
- MVVM in Angular — Angular service + async pipe integration