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 callsfnimmediately 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-corewas built on RxJSBehaviorSubject. It has since migrated fully to@web-loom/signals-core; there is norxjsdependency left in the package. RxJS remains available only as an optional escape hatch via@web-loom/signals-core/rxjsif 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 stateRestfulApiModel— standard REST CRUD with automatic command wiring
QueryStateModel(for@web-loom/query-core-managed caching/deduplication) exists inmvvm-core's source but is not yet exported from its public entry point. ComposeBaseModelwithquery-coredirectly 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 asubmitCommandQueryableCollectionViewModel<T>— adds client-side text filter (debounced viadebouncedSignal), 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-corewith 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$—truewhen the command is allowed to run. Combines the base guard, anyobservesCanExecute()conditions, and!isExecuting. Bind todisabledon a button.isExecuting$—truewhile the async function is running. Bind to a spinner or loading text.executeError$— emits the last thrown error,nullafter 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 changedobservesCanExecute(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:
- Every registered Command has
.dispose()called - Every teardown added via
addSubscriptionis invoked, in registration order - 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(extendsBaseViewModel)
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'
- Order doesn't matter →
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
- Models — full API for BaseModel and RestfulApiModel
- ViewModels — full API for BaseViewModel and RestfulApiViewModel
- Signals Core — the
signal/computed/effectprimitives underlying all of this - MVVM in React — how to wire ViewModels into React components
- MVVM in Vue — Vue 3 composable integration
- MVVM in Angular — signal bridging and DI tokens
- MVVM in Vanilla TS — manual subscription and DOM rendering