Web Loom logo
MVVM CoreCore Concepts

Core Concepts

The foundational ideas behind MVVM Core — how Models, ViewModels, Commands, and the dispose pattern work together to produce a testable, framework-agnostic architecture.

Core Concepts

@web-loom/mvvm-core is the architectural foundation of Web Loom. It provides the base classes, interfaces, and patterns that keep business logic portable across React, Vue, Angular, and any other runtime. This page explains the why behind each concept — the design decisions, the contracts, and how the pieces compose. For the full API reference, see the Models and ViewModels pages.


The Reactive Spine — Signals

The entire library is built on a single primitive: ReadonlySignal<T> from @web-loom/signals-core.

A signal is a reactive value that:

  • Always holds a current value (reads synchronously via .get() or .peek())
  • Notifies subscribers when the value changes via .subscribe(fn) — future changes only, no replay
  • Can be composed into derived values with computed(() => ...), which auto-recomputes when any signal it reads changes
  • Has a companion helper, observe(sig, fn), that calls fn immediately with the current value and then subscribes — the BehaviorSubject-style "replay on subscribe" behavior, opted into explicitly

Every Model exposes three signal-backed properties:

data$       — the current payload (null | T | T[])
isLoading$  — true while a fetch or mutation is in progress
error$      — the last thrown error, or null

ViewModels read these signals, derive new signals from them with computed(), and re-expose the results. Views read ViewModel signals (via a small framework-specific bridge — see Framework Integration). State flows in one direction — Model → ViewModel → View — and actions flow back up through Commands.

This is the core loop. Everything else in the library supports it.

Prior to mid-2026, mvvm-core was built on RxJS BehaviorSubject. It has since migrated fully to @web-loom/signals-core; there is no rxjs dependency left in the package. RxJS remains available only as an optional escape hatch via @web-loom/signals-core/rxjs if you need to bridge into an existing RxJS codebase.


Models

A Model is a plain TypeScript class responsible for:

  • Fetching, persisting, and owning raw data
  • Maintaining the three reactive streams (data$, isLoading$, error$)
  • Validating incoming data against a Zod schema
  • Never importing anything from a UI framework

The Model contract

All models implement IBaseModel, which defines the public interface:

interface IBaseModel<TData, TSchema> extends IDisposable {
  readonly data$: ReadonlySignal<TData | null>;
  readonly isLoading$: ReadonlySignal<boolean>;
  readonly error$: ReadonlySignal<any>;
 
  setData(newData: TData | null): void;
  setLoading(status: boolean): void;
  setError(err: any): void;
  clearError(): void;
  validate(data: any): TData;
  getCurrentData(): TData | null;
}

The set* methods are the only way to change state from inside a subclass. Consumers (ViewModels) receive read-only signals — they cannot write to the underlying signal directly, only read (.get()/.peek()) or subscribe.

BaseModel

BaseModel<TData, TSchema> is the foundation. Extend it when you want to manage the full fetch/mutation lifecycle yourself:

import { BaseModel } from '@web-loom/mvvm-core';
import { z } from 'zod';
 
const TaskSchema = z.array(
  z.object({ id: z.string(), title: z.string(), done: z.boolean() }),
);
type TaskList = z.infer<typeof TaskSchema>;
 
class TaskModel extends BaseModel<TaskList, typeof TaskSchema> {
  constructor() {
    super({ initialData: [], schema: TaskSchema });
  }
 
  async fetchAll() {
    this.setLoading(true);
    this.clearError();
    try {
      const res  = await fetch('/api/tasks');
      const data = await res.json();
      this.setData(data);
    } catch (err) {
      this.setError(err);
    } finally {
      this.setLoading(false);
    }
  }
 
  async create(title: string) {
    const res  = await fetch('/api/tasks', {
      method: 'POST',
      body: JSON.stringify({ title }),
    });
    const task = await res.json();
    const current = this.getCurrentData() ?? [];
    this.setData([...current, task]);
  }
}

RestfulApiModel

RestfulApiModel<TData, TSchema> is a higher-level subclass that adds the full CRUD lifecycle — fetch, create, update, delete — wired to a configurable HTTP endpoint. Use this instead of BaseModel when your data source follows standard REST conventions:

import { RestfulApiModel } from '@web-loom/mvvm-core';
import { GreenhouseListSchema, type GreenhouseListData } from './schemas';
 
export class GreenHouseModel extends RestfulApiModel<GreenhouseListData, typeof GreenhouseListSchema> {
  constructor() {
    super({
      baseUrl: 'http://localhost:3001',
      endpoint: '/greenhouses',
      fetcher: fetch,
      schema: GreenhouseListSchema,
      initialData: [],
    });
  }
}

RestfulApiModel exposes Commands (fetchCommand, createCommand, updateCommand, deleteCommand) that manage loading/error state automatically.

When to use which

  • BaseModel — custom fetch logic, non-REST APIs, GraphQL, WebSockets, local state
  • RestfulApiModel — standard REST CRUD with automatic command wiring

QueryStateModel (for @web-loom/query-core-managed caching/deduplication) exists in mvvm-core's source but is not yet exported from its public entry point. Compose BaseModel with query-core directly instead — see Query Core.


ViewModels

A ViewModel sits between the Model and the View. It:

  • Reads Model signals and registers any manual subscriptions via addSubscription() for safe disposal
  • Derives presentation state using computed()
  • Exposes Commands for every user action
  • Contains no framework imports — it is plain TypeScript

The ViewModel contract

BaseViewModel<TModel> re-exposes the model's three signals and adds a fourth:

class BaseViewModel<TModel extends BaseModel<any, any>> {
  readonly data$:             ReadonlySignal<TModel['data']>;
  readonly isLoading$:        ReadonlySignal<boolean>;
  readonly error$:            ReadonlySignal<any>;
  readonly validationErrors$: ReadonlySignal<ZodError | null>;  // computed from error$
}

validationErrors$ is a computed() that resolves to a ZodError instance when error$ contains a Zod validation failure, and null otherwise. You never construct it manually.

Deriving presentation state

The ViewModel is where raw Model data is shaped for display:

import { BaseViewModel, Command } from '@web-loom/mvvm-core';
import { computed } from '@web-loom/signals-core';
import { TaskModel } from './TaskModel';
 
class TaskListViewModel extends BaseViewModel<TaskModel> {
  // Derived display state
  readonly pendingCount$ = computed(() => (this.data$.get() ?? []).filter((t) => !t.done).length);
  readonly isEmpty$ = computed(() => (this.data$.get() ?? []).length === 0);
  readonly hasError$ = computed(() => Boolean(this.error$.get()));
 
  // registerCommand() ties Command disposal into vm.dispose()
  readonly fetchCommand = this.registerCommand(
    new Command(async () => this.model.fetchAll()),
  );
 
  readonly addCommand = this.registerCommand(
    new Command(async (title: string) => this.model.create(title)),
  );
 
  constructor(private model: TaskModel) {
    super(model);
  }
}

RestfulApiViewModel

RestfulApiViewModel<TData, TSchema> pairs with RestfulApiModel and pre-wires the four CRUD Commands. Extend it when your ViewModel primarily drives a REST resource:

import { RestfulApiViewModel, createReactiveViewModel } from '@web-loom/mvvm-core';
 
// Factory approach (most common in Web Loom apps)
export const greenHouseViewModel = createReactiveViewModel({
  modelConfig: greenHouseConfig,
  schema: GreenhouseListSchema,
});
 
// greenHouseViewModel exposes:
//   .fetchCommand    .createCommand
//   .updateCommand   .deleteCommand
//   .data$           .isLoading$
//   .error$          .selectedItem$

FormViewModel and QueryableCollectionViewModel

  • FormViewModel<TData> — manages form field state, dirty/valid flags, field-level errors, and a submitCommand
  • QueryableCollectionViewModel<T> — adds client-side text filter (debounced via debouncedSignal), multi-key sort, and pagination on top of a list signal

Both classes exist in mvvm-core's source but are not yet exported from its public entry point — treat them as internal/unstable. For production form handling, use @web-loom/forms-core with its framework adapters instead.


Commands

Commands are the primary mechanism for user-initiated actions. Rather than calling async functions directly from the View, you call command.execute(). The Command manages loading state, error capture, and concurrency control internally.

What a Command exposes

interface ICommand<TParam = void, TResult = void> extends IDisposable {
  readonly canExecute$:  ReadonlySignal<boolean>;
  readonly isExecuting$: ReadonlySignal<boolean>;
  readonly executeError$: ReadonlySignal<any>;
 
  execute(param: TParam): Promise<TResult | undefined>;
}
  • canExecute$true when the command is allowed to run. Combines the base guard, any observesCanExecute() conditions, and !isExecuting. Bind to disabled on a button.
  • isExecuting$true while the async function is running. Bind to a spinner or loading text.
  • executeError$ — emits the last thrown error, null after a successful execution. Bind to an error message element.

Basic usage

const saveCommand = new Command(async (payload: FormData) => {
  await api.save(payload);
});
 
// Bind in the View
<button
  onClick={() => saveCommand.execute(formData)}
  disabled={!canExecute}   // from canExecute$
>
  {isExecuting ? 'Saving…' : 'Save'}   // from isExecuting$
</button>

Guards with observesCanExecute and observesProperty

Attach runtime conditions to canExecute$ without rebuilding the Command:

readonly submitCommand = new Command(async () => {
  await this.model.submit(this.formData);
})
  .observesCanExecute(this.isFormValid$)   // disabled if form is invalid
  .observesProperty(this.hasUnsavedChanges$); // disabled if nothing changed

observesCanExecute(sig) requires sig to currently read true for the command to be executable. observesProperty(sig) requires sig to currently read a truthy value. Both conditions are combined with &&. isExecuting is always combined too — a Command cannot re-enter while it is already running. canExecute$ is itself a computed(), so it automatically re-evaluates whenever any signal read inside a guard changes; call command.raiseCanExecuteChanged() to force a re-evaluation when a guard reads plain (non-signal) state instead.

Fluent builder

For complex guards, use the builder API:

const deleteCommand = Command.create<string, void>()
  .withExecute(async (id) => {
    await this.model.delete(id);
  })
  .withCanExecute(computed(() => Boolean(this.selectedId$.get())))
  .build();

CompositeCommand

CompositeCommand aggregates multiple Commands into one. Its canExecute$ is true only if all registered commands can execute. Its isExecuting$ is true if any registered command is executing.

import { CompositeCommand } from '@web-loom/mvvm-core';
 
// Parallel (default) — all commands run concurrently
const refreshAll = new CompositeCommand({ executionMode: 'parallel' });
refreshAll.register(this.fetchGreenhousesCommand);
refreshAll.register(this.fetchSensorsCommand);
refreshAll.register(this.fetchAlertsCommand);
 
await refreshAll.execute(); // fires all three simultaneously
 
// Sequential — commands run one after another in registration order
const setupWizard = new CompositeCommand({ executionMode: 'sequential' });
setupWizard.register(this.validateProfileCommand);
setupWizard.register(this.createAccountCommand);
setupWizard.register(this.sendWelcomeEmailCommand);

The Dispose Pattern

ViewModels subscribe to signals. If those subscriptions are never cleaned up, the subscriber stays alive in memory even after the UI component is gone — a classic memory leak.

MVVM Core addresses this with two registration mechanisms that dispose() runs through.

IDisposable

Every core class implements IDisposable:

interface IDisposable {
  dispose(): void;
}

Call vm.dispose() in your framework's teardown hook. This triggers a coordinated cleanup sequence.

Note that data$/isLoading$/error$ are the same signal instances exposed by the Model (not derived copies), so there is nothing to unsubscribe on them specifically — disposal is about the subscriptions and Commands you create inside the ViewModel, tracked via registerCommand/addSubscription below.

registerCommand

Register Commands created inside the ViewModel so they are automatically disposed:

class MyViewModel extends BaseViewModel<MyModel> {
  // registerCommand returns the command — assign it directly
  readonly saveCommand = this.registerCommand(
    new Command(async (data) => this.model.save(data)),
  );
}

When dispose() runs, it calls saveCommand.dispose() automatically. You never manage Command cleanup manually.

addSubscription

For manual subscriptions that aren't attached to Commands, use addSubscription:

constructor(private model: MyModel) {
  super(model);
  this.addSubscription(
    this.data$.subscribe((data) => this.computeDerivedState(data)),
  );
}

All subscriptions added this way are unsubscribed when dispose() runs.

The full disposal sequence

When vm.dispose() is called:

  1. Every registered Command has .dispose() called
  2. Every teardown added via addSubscription is invoked, in registration order
  3. Internal bookkeeping is cleared, so a second dispose() call is a safe no-op

In framework teardown hooks:

// React
useEffect(() => {
  vm.fetchCommand.execute();
  return () => vm.dispose();
}, []);
 
// Vue
onUnmounted(() => vm.dispose());
 
// Angular
ngOnDestroy() { this.vm.dispose(); }

Zod Validation

MVVM Core uses Zod for schema-based validation at the Model boundary.

Where validation runs

Validation is a Model-layer concern, not a ViewModel concern. The schema is passed to BaseModel at construction time:

const GreenhouseSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  location: z.string(),
  size: z.enum(['25sqm', '50sqm', '100sqm']),
  cropType: z.string().optional(),
});
 
class GreenHouseModel extends BaseModel<z.infer<typeof GreenhouseSchema>, typeof GreenhouseSchema> {
  constructor() {
    super({ initialData: null, schema: GreenhouseSchema });
  }
}

validate()

Call this.validate(data) inside a Model method to parse and type-check incoming API data:

async fetch(id: string) {
  this.setLoading(true);
  try {
    const raw = await fetch(`/api/greenhouses/${id}`).then(r => r.json());
    const validated = this.validate(raw); // throws ZodError if shape is wrong
    this.setData(validated);
  } catch (err) {
    this.setError(err);
  } finally {
    this.setLoading(false);
  }
}

validationErrors$ in the ViewModel

BaseViewModel derives validationErrors$ from error$ with computed(). When a ZodError lands in error$, validationErrors$ resolves to it. This lets Views display field-specific error messages without knowing anything about Zod:

// In the View (React, via the useSignal bridge — see MVVM in React)
const validationErrors = useSignal(vm.validationErrors$);
 
if (validationErrors) {
  const nameError = validationErrors.issues.find(i => i.path[0] === 'name');
  // render nameError?.message
}

Schema validation is optional — pass schema: undefined and validate() becomes a no-op.


Business Data vs UI State

One of the most important distinctions in the architecture:

Business data belongs in Models. A filter value that changes the API query, a selected item ID that drives a detail view, a list of records from the server — these live in data$ on a Model.

UI-only state belongs in store-core. Whether a drawer is open, which tab is active, whether a tooltip is visible — these live in a createStore(...) store. They have nothing to do with server data and should not pollute your Model's reactive state.

// Good — API-relevant state in the Model
class TaskModel extends BaseModel<TaskList, typeof TaskSchema> {
  private statusFilter$ = signal<'all' | 'done' | 'pending'>('all');
 
  async fetchFiltered() {
    const filter = this.statusFilter$.peek();
    const res = await fetch(`/api/tasks?status=${filter}`);
    this.setData(await res.json());
  }
}
 
// Good — ephemeral UI state in a store
const uiStore = createStore(
  { drawerOpen: false, activeTab: 'list' as const },
  (set) => ({
    toggleDrawer: () => set((s) => ({ ...s, drawerOpen: !s.drawerOpen })),
    setTab: (tab: string) => set((s) => ({ ...s, activeTab: tab })),
  }),
);

If you find yourself putting sidebarOpen into a signal on a ViewModel, it belongs in a Store. If you find yourself putting apiResults into a Store, it belongs in a Model.


Event Bus — Cross-Feature Communication

When two features need to react to each other without being directly coupled, use @web-loom/event-bus-core rather than passing observables between ViewModels or calling methods across boundaries.

import { createEventBus } from '@web-loom/event-bus-core';
 
// Define typed events
const bus = createEventBus<{
  'greenhouse:created': { id: string; name: string };
  'sensor:alert':       { sensorId: string; value: number };
}>();
 
// In GreenhouseViewModel — publish after create
readonly createCommand = this.registerCommand(
  new Command(async (payload) => {
    const result = await this.model.create(payload);
    bus.emit('greenhouse:created', { id: result.id, name: result.name });
  }),
);
 
// In AlertViewModel — react without knowing about GreenhouseViewModel
constructor() {
  const onCreated = ({ name }: { id: string; name: string }) => {
    console.log(`New greenhouse "${name}" — refreshing alert rules`);
    this.fetchCommand.execute();
  };
  bus.on('greenhouse:created', onCreated);
  this.addSubscription(() => bus.off('greenhouse:created', onCreated));
}

The Event Bus is a cross-cutting concern — it is not a layer in the MVVM stack. Use it for genuinely independent features that need loose coordination.


The Complete Data Flow

Tracing one cycle from user action to re-render:

1. User clicks "Delete" button in the View
         ↓
2. View calls vm.deleteCommand.execute(id)
         ↓
3. Command sets isExecuting$ = true
         ↓
4. Command calls the async execute function
         ↓
5. ViewModel's execute function calls model.delete(id)
         ↓
6. RestfulApiModel sends DELETE /api/greenhouses/:id
         ↓
7. Model sets isLoading$ = true (via setLoading)
         ↓
8. API responds — Model filters the item out of current data
   and calls setData(updatedList)
         ↓
9. model.data$ signal updates to the new list
         ↓
10. vm.data$ (the same signal instance) notifies its subscribers
         ↓
11. View re-renders the list without the deleted item
         ↓
12. Command sets isExecuting$ = false
         ↓
13. Model sets isLoading$ = false

The ViewModel never touches the DOM. The Model never imports from the framework. The View never calls fetch or manages loading flags.


Testing

Because ViewModels and Models have no framework imports and no DOM dependencies, they can be tested with plain Vitest.

Testing a Model

import { describe, it, expect, vi } from 'vitest';
import { TaskModel } from './TaskModel';
 
describe('TaskModel', () => {
  it('sets data$ after a successful fetch', async () => {
    const model = new TaskModel();
 
    global.fetch = vi.fn().mockResolvedValue({
      json: () => Promise.resolve([{ id: '1', title: 'Buy milk', done: false }]),
    } as any);
 
    await model.fetchAll();
 
    expect(model.getCurrentData()).toHaveLength(1);
    expect(model.getCurrentData()![0].title).toBe('Buy milk');
    model.dispose();
  });
 
  it('sets error$ when fetch fails', async () => {
    const model = new TaskModel();
    global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
 
    await model.fetchAll();
 
    expect(model.getCurrentError()).toBeInstanceOf(Error);
    expect(model.getCurrentLoadingStatus()).toBe(false);
    model.dispose();
  });
});

Testing a ViewModel

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { signal, type WritableSignal } from '@web-loom/signals-core';
import { TaskListViewModel } from './TaskListViewModel';
import { TaskModel } from './TaskModel';
 
function makeMockModel() {
  const model = {
    data$:      signal(null),
    isLoading$: signal(false),
    error$:     signal(null),
    fetchAll:   vi.fn().mockResolvedValue(undefined),
    create:     vi.fn().mockResolvedValue(undefined),
    dispose:    vi.fn(),
  } as unknown as TaskModel;
  return model;
}
 
describe('TaskListViewModel', () => {
  let vm: TaskListViewModel;
  let model: TaskModel;
 
  beforeEach(() => {
    model = makeMockModel();
    vm = new TaskListViewModel(model);
  });
 
  afterEach(() => vm.dispose());
 
  it('pendingCount$ reflects undone tasks', () => {
    (model.data$ as WritableSignal<any>).set([
      { id: '1', title: 'A', done: false },
      { id: '2', title: 'B', done: true },
      { id: '3', title: 'C', done: false },
    ]);
    expect(vm.pendingCount$.peek()).toBe(2);
  });
 
  it('calls model.fetchAll when fetchCommand executes', async () => {
    await vm.fetchCommand.execute();
    expect(model.fetchAll).toHaveBeenCalledOnce();
  });
 
  it('isExecuting$ is true during command execution', async () => {
    let wasExecuting = false;
    const unsubscribe = vm.fetchCommand.isExecuting$.subscribe((v) => {
      if (v) wasExecuting = true;
    });
    await vm.fetchCommand.execute();
    unsubscribe();
    expect(wasExecuting).toBe(true);
  });
});

No TestBed, no component mounting, no DOM. The ViewModel is a plain object.


Choosing the Right Class

Which Model base class?

  • Need full control over fetch logic or a non-REST API → BaseModel
  • Standard REST CRUD with automatic commands → RestfulApiModel

Which ViewModel base class?

  • Generic domain logic, custom derived state → BaseViewModel
  • Driving a REST resource with standard CRUD → RestfulApiViewModel (extends BaseViewModel)

Command vs CompositeCommand?

  • Single async operation → Command
  • Multiple commands that must run together → CompositeCommand
    • Order doesn't matter → executionMode: 'parallel' (default)
    • Sequential steps in a workflow → executionMode: 'sequential'

Do I need RxJS at all?

No, not for anything covered on this page — data$/isLoading$/error$/canExecute$/isExecuting$ are all @web-loom/signals-core signals, and computed()/effect() cover the composition RxJS operators used to handle. Reach for the optional @web-loom/signals-core/rxjs interop only if you're bridging into an existing RxJS-based codebase or dependency.


Where to Go Next

Was this helpful?
Web Loom logo
Copyright © Web Loom. All rights reserved.