Web Loom logo
MVVM CoreViewModels

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> and QueryableCollectionViewModel<T> also exist in mvvm-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 data
  • isLoading$true while a fetch or mutation is in flight
  • error$ — the last error from the model, or null
  • validationErrors$ — a computed() from error$; resolves to a ZodError when the model receives invalid API data, null otherwise

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$true while the async function is running; use for spinners and button states
  • canExecute$true when the command is allowed to run; bind to disabled on buttons. It's a computed(), so it re-evaluates automatically whenever a signal read inside a guard changes
  • executeError$ — the last error thrown by the execute function, or null

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 force canExecute$ 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 — executes model.fetch(id?). Pass a string ID or array of IDs, or nothing for the full collection.
  • createCommand — executes model.create(payload) with an optimistic add.
  • updateCommand — executes model.update(id, payload) with an optimistic patch. Payload shape: { id: string; payload: Partial<Item> }.
  • deleteCommand — executes model.delete(id) with an optimistic remove.

Item Selection

RestfulApiViewModel also maintains a selection state for list-detail patterns:

  • selectedItem$ — a computed() signal for the currently selected item, derived from data$ and an internal selected-id signal
  • 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 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 a submitCommand, independent of any Model
  • QueryableCollectionViewModel<T> — would layer client-side text filtering (debounced via debouncedSignal), 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-core with its React/Vue/vanilla adapters.
  • For filter/sort/pagination, derive the state yourself with computed() and a debouncedSignal() over an ObservableCollection's items$, or a plain signal() 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 a computed() of token$ — it's a separate signal kept in sync via observe(), because it needs an immediate initial value derived from the token at construction time, not just future changes
  • A local _sessionResult signal for state that lives in the ViewModel, not the Model
  • signInCommand/signUpCommand/signOutCommand are plain fields (not wrapped in registerCommand()), so dispose() 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 when RestfulApiViewModel's CRUD commands don't match your API shape
  • RestfulApiViewModel — 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/effect primitives 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
Was this helpful?
Web Loom logo
Copyright © Web Loom. All rights reserved.