Models
Deep dive into the Model layer — BaseModel, RestfulApiModel, QueryStateModel, and real-world implementations from the Web Loom example apps.
Models
The Model is the data layer of the MVVM pattern. It owns raw data, communicates with APIs, and exposes reactive signals that ViewModels read. A well-designed Model has no knowledge of the UI — no framework imports, no presentation logic.
@web-loom/mvvm-core ships model base classes that cover the most common data patterns, built on @web-loom/signals-core.
Class Hierarchy
BaseModel<TData, TSchema>
└── RestfulApiModel<TData, TSchema> — fetch + CRUD via HTTP
QueryStateModel<TData, TSchema>(reading via a@web-loom/query-corecache) exists inmvvm-core's source but isn't exported from its public entry point yet — see the note in the QueryStateModel section below.
Both classes expose the same reactive signals and implement IDisposable.
BaseModel
BaseModel is the foundation for every model in the system. It wraps three signals (from @web-loom/signals-core) and provides methods to update them safely.
Constructor
import { BaseModel } from '@web-loom/mvvm-core';
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
class UserModel extends BaseModel<z.infer<typeof UserSchema>, typeof UserSchema> {
constructor() {
super({
initialData: null,
schema: UserSchema,
});
}
}Constructor options:
initialData— the starting value fordata$. Usenullfor a single resource that hasn't loaded yet, or[]for an empty list.schema— a Zod schema used byvalidate()to check incoming data at runtime. Passundefinedto skip validation entirely.
Reactive Signals
Every BaseModel exposes three public signals:
data$—ReadonlySignal<TData | null>holding the current valueisLoading$—ReadonlySignal<boolean>for loading stateerror$—ReadonlySignal<any>for the last error
Read the current value synchronously with .get() (tracked, for use inside computed()/effect()) or .peek() (untracked). .subscribe(fn) only fires on future changes — it does not replay the current value to a new subscriber. If you need BehaviorSubject-style "deliver now, then on every change" semantics, use observe(sig, fn) from @web-loom/signals-core instead of .subscribe().
Protected Methods
Subclasses (and ViewModels, rarely) call these methods to update state:
setData(data)— updatedata$with a new valuesetLoading(isLoading)— toggle the loading flagsetError(error)— store a caught error inerror$clearError()— reseterror$tonullvalidate(data)— run the Zod schema; throwsZodErroron invalid shape
Snapshot Getters
For non-reactive reads (e.g. inside imperative methods):
getCurrentData()— synchronous current value ofdata$getCurrentLoadingStatus()— synchronous current value ofisLoading$getCurrentError()— synchronous current value oferror$
Disposal
model.dispose();Marks the model disposed: setData/setLoading/setError/clearError all become no-ops afterward, so nothing can push further updates through data$/isLoading$/error$. It does not call .complete() on anything (signals don't have that concept) and does not itself unsubscribe existing subscribers — subscribers simply stop receiving updates because nothing will call the setters again. Always call this when the model is no longer needed.
Manual Model Example
Use BaseModel directly when your data doesn't come from a REST API — for example, local-only state, WebSocket feeds, or computed aggregates:
class LocalCartModel extends BaseModel<CartItem[], typeof CartItemListSchema> {
constructor() {
super({ initialData: [], schema: CartItemListSchema });
}
addItem(item: CartItem) {
const current = this.getCurrentData() ?? [];
this.setData([...current, item]);
}
removeItem(id: string) {
const current = this.getCurrentData() ?? [];
this.setData(current.filter(i => i.id !== id));
}
clearCart() {
this.setData([]);
}
}RestfulApiModel
RestfulApiModel extends BaseModel to add full CRUD operations against an HTTP endpoint. It handles loading states, error capture, schema validation, and optimistic updates automatically.
Constructor
import { RestfulApiModel } from '@web-loom/mvvm-core';
export type TConstructorInput<TData, TSchema> = {
baseUrl: string | null;
endpoint: string | null;
fetcher: Fetcher | null;
schema: TSchema;
initialData: TData | null;
validateSchema?: boolean;
};baseUrl— root URL, e.g.'https://api.example.com'endpoint— path segment, e.g.'/users'fetcher— a function(url, options?) => Promise<unknown>. Use your project's HTTP abstraction here.validateSchema— set tofalseto skip Zod validation on API responses (useful in development or for untrusted schemas)
CRUD Methods
fetch(id?)
await model.fetch(); // GET /users
await model.fetch('42'); // GET /users/42Sets isLoading$ to true, calls the fetcher, validates via Zod (if enabled), updates data$, then resets loading. On error, stores in error$.
create(payload)
const newUser = await model.create({ name: 'Alice', email: 'alice@example.com' });Optimistic update: immediately appends the new item to data$ with a temporary id prefix. Confirms the real record from the server response. Rolls back to the previous state if the request fails.
update(id, payload)
await model.update('42', { name: 'Alice Smith' });Optimistic update: immediately applies the patch to the matching item in data$. On failure, reverts to the pre-update state.
delete(id)
await model.delete('42');Optimistic update: immediately removes the item from data$. Restores it if the server returns an error.
Optimistic Update Flow
User action
↓
Snapshot current data$ ← saved for rollback
↓
Apply change to data$ immediately ← UI responds instantly
↓
Call HTTP endpoint
├── Success → confirm change (no-op, already applied)
└── Failure → restore snapshot, set error$
This pattern keeps the UI feeling fast without requiring the ViewModel to manage rollback logic.
Concrete Example: GreenHouseModel
From packages/models/src/GreenHouseModel.ts — a minimal model managing a list of greenhouses:
import { RestfulApiModel } from '@web-loom/mvvm-core';
import { z } from 'zod';
import { nativeFetcher } from '@repo/http';
import { apiRegistry } from '@repo/api-registry';
const CreateGreenhouseSchema = z.object({
id: z.string().uuid().optional(),
name: z.string().min(1),
location: z.string().min(1),
size: z.string().min(1),
cropType: z.string().optional(),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
});
const GreenhouseListSchema = z.array(CreateGreenhouseSchema);
type GreenhouseListData = z.infer<typeof GreenhouseListSchema>;
export class GreenHouseModel extends RestfulApiModel<
GreenhouseListData,
typeof GreenhouseListSchema
> {
constructor() {
super({
baseUrl: API_BASE_URL,
endpoint: apiRegistry.greenhouses.path,
fetcher: nativeFetcher,
schema: GreenhouseListSchema,
initialData: [],
validateSchema: false,
});
}
}Key decisions here:
initialData: []— list models start empty, not nullvalidateSchema: false— skips Zod validation for performance in this case- The model itself has no methods beyond what
RestfulApiModelprovides
Concrete Example: SensorModel
From packages/models/src/SensorModel.ts — demonstrates a custom fetcher for caching:
import { SensorTypeEnum, SensorStatusEnum } from './schemas';
const CreateSensorSchema = z.object({
id: z.string().uuid().optional(),
type: SensorTypeEnum, // z.enum(['temperature', 'humidity', 'soilMoisture', 'lightIntensity'])
status: SensorStatusEnum, // z.enum(['active', 'inactive'])
greenhouseId: z.number().int().positive(),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
greenhouse: CreateGreenhouseSchema,
});
const SensorListSchema = z.array(CreateSensorSchema);
type SensorListData = z.infer<typeof SensorListSchema>;
export class SensorModel extends RestfulApiModel<SensorListData, typeof SensorListSchema> {
constructor() {
super({
baseUrl: API_BASE_URL,
endpoint: apiRegistry.sensors.path,
fetcher: fetchWithCache, // custom fetcher with in-memory cache
schema: SensorListSchema,
initialData: [] as SensorListData,
validateSchema: false,
});
}
}Concrete Example: AuthModel
From packages/models/src/AuthModel.ts — a model with authentication-specific state and custom methods:
const UserSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
firstName: z.string().optional().nullable(),
lastName: z.string().optional().nullable(),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
});
export class AuthModel extends RestfulApiModel<UserData, typeof UserSchema> {
private readonly _token = signal<string | null>(null);
public readonly token$ = this._token.asReadonly();
constructor() {
const storedToken = readTokenFromStorage(); // restore session from localStorage
const authFetcher = createAuthFetcher(storedToken);
super({
baseUrl: API_BASE_URL,
endpoint: apiRegistry.auth.me.path,
fetcher: authFetcher.fetcher,
schema: UserSchema,
initialData: null,
validateSchema: true,
});
}
async signIn(payload: SignInPayload) {
this.setLoading(true);
try {
const response = await callSignIn(payload);
this._token.set(response.token);
persistToken(response.token);
await this.fetch(); // load the user profile after sign in
} catch (err) {
this.setError(err as Error);
} finally {
this.setLoading(false);
}
}
async signOut() {
this._token.set(null);
clearToken();
this.setData(null);
}
get isAuthenticated(): boolean {
return this._token.peek() !== null;
}
get token(): string | null {
return this._token.peek();
}
}AuthModel shows the pattern for adding model-specific observables (token$) and custom imperative methods (signIn, signOut) on top of RestfulApiModel.
QueryStateModel (internal, not yet exported)
mvvm-core's source includes a QueryStateModel that extends BaseModel to integrate with @web-loom/query-core for caching and stale-while-revalidate patterns — instead of managing its own HTTP calls, it would read from QueryCore's endpoint state (refetch(force?) to trigger a refetch, invalidate() to mark cached data stale). It is not exported from mvvm-core's public entry point, so import { QueryStateModel } from '@web-loom/mvvm-core' does not currently compile — treat it as internal/unstable.
Until it's exported, compose BaseModel with @web-loom/query-core directly: subscribe to the QueryCore endpoint's state yourself inside a BaseModel subclass, calling setData/setLoading/setError from that subscription. See Query Core for the QueryCore API itself.
TodoItem — Local State Model
For state that never leaves the browser, extend BaseModel directly without HTTP concerns:
// From packages/mvvm-core/src/examples/
const TodoSchema = z.object({
id: z.string().uuid(),
text: z.string().min(1),
isCompleted: z.boolean(),
createdAt: z.string().datetime().optional(),
});
type TodoData = z.infer<typeof TodoSchema>;
class TodoListModel extends BaseModel<TodoData[], typeof z.array(TodoSchema)> {
constructor() {
super({ initialData: [], schema: z.array(TodoSchema) });
}
addTodo(text: string) {
const todo: TodoData = {
id: crypto.randomUUID(),
text,
isCompleted: false,
createdAt: new Date().toISOString(),
};
this.setData([...(this.getCurrentData() ?? []), todo]);
}
toggleTodo(id: string) {
const current = this.getCurrentData() ?? [];
this.setData(current.map(t => t.id === id ? { ...t, isCompleted: !t.isCompleted } : t));
}
removeTodo(id: string) {
this.setData((this.getCurrentData() ?? []).filter(t => t.id !== id));
}
}Zod Schema Patterns
Every model should define its schema in a separate file alongside the model:
// schemas/greenhouse.schema.ts
import { z } from 'zod';
// Enum values as Zod enums — not TypeScript enums
export const SensorTypeSchema = z.enum(['temperature', 'humidity', 'soilMoisture', 'lightIntensity']);
export const SensorStatusSchema = z.enum(['active', 'inactive']);
// Optional timestamps — these come from the server, not the client
export const GreenhouseSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
location: z.string().min(1),
cropType: z.string().optional(),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
});
// Derive types from schemas — don't write them twice
export type Greenhouse = z.infer<typeof GreenhouseSchema>;
export const GreenhouseListSchema = z.array(GreenhouseSchema);
export type GreenhouseList = z.infer<typeof GreenhouseListSchema>;Schema patterns used consistently across Web Loom:
.uuid()for server-generated IDs; use.uuid().or(z.string().startsWith('temp_'))when supporting optimistic creates that haven't been confirmed yet.string().datetime().optional()for timestamps that the server owns.min(1)on required strings to catch empty inputs early- Schema → array schema pattern: define
ItemSchema, deriveItemListSchema = z.array(ItemSchema), derive types from both
Model Lifecycle
Constructor called
↓
Signals initialized with initialData
↓
ViewModel calls fetch() / refetch()
↓
Model sets isLoading$ = true
↓
HTTP request completes
↓
Model validates response, updates data$
↓
Model sets isLoading$ = false
↓
ViewModel reads signals, derives state via computed(), renders
↓
Component unmounts → ViewModel.dispose() called
↓
Model disposed explicitly (see below) → setters become no-ops
BaseViewModel.dispose() does not automatically dispose an injected Model — it only disposes registered Commands and runs teardowns added via addSubscription() (there is no addDisposable() method). If your ViewModel constructs its own Model internally, dispose it explicitly in an overridden dispose():
class UserListViewModel extends BaseViewModel<GreenHouseModel> {
constructor() {
super(new GreenHouseModel());
}
dispose() {
super.dispose(); // registered commands + addSubscription teardowns
this.model.dispose();
}
}If the Model is injected from outside (the more common pattern — see Don't: Create Models Inside the ViewModel), dispose it wherever it was constructed instead.
Testing Models
Models are plain TypeScript — test them with Vitest directly, no DOM setup needed.
Testing BaseModel
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import { BaseModel } from '@web-loom/mvvm-core';
const Schema = z.object({ id: z.string(), value: z.number() });
class TestModel extends BaseModel<z.infer<typeof Schema>, typeof Schema> {
constructor(initial = null) {
super({ initialData: initial, schema: Schema });
}
updateValue(data: z.infer<typeof Schema>) { this.setData(data); }
}
describe('TestModel', () => {
it('starts with null data and false loading', () => {
const model = new TestModel();
expect(model.data$.peek()).toBeNull();
expect(model.isLoading$.peek()).toBe(false);
model.dispose();
});
it('validates incoming data against schema', () => {
const model = new TestModel();
expect(() => model.updateValue({ id: 'x', value: 42 })).not.toThrow();
// @ts-expect-error — invalid shape
expect(() => model['validate']({ id: 123 })).toThrow();
model.dispose();
});
});Testing RestfulApiModel
Mock the fetcher to avoid real HTTP calls:
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { RestfulTodoListModel } from '@web-loom/mvvm-core/examples';
const mockFetcher = vi.fn();
const createModel = () =>
new RestfulTodoListModel({
baseUrl: 'https://api.test.com',
endpoint: '/todos',
fetcher: mockFetcher,
initialData: [],
});
describe('RestfulTodoListModel', () => {
beforeEach(() => mockFetcher.mockReset());
it('fetches and populates data$', async () => {
const items = [{ id: '1', text: 'Buy milk', isCompleted: false }];
mockFetcher.mockResolvedValue(items);
const model = createModel();
await model.fetch();
expect(model.data$.peek()).toEqual(items);
expect(model.isLoading$.peek()).toBe(false);
model.dispose();
});
it('sets error$ on fetch failure', async () => {
mockFetcher.mockRejectedValue(new Error('Network error'));
const model = createModel();
await model.fetch();
expect(model.error$.peek()).toBeInstanceOf(Error);
model.dispose();
});
it('rolls back optimistic create on failure', async () => {
mockFetcher.mockRejectedValue(new Error('Server error'));
const model = createModel();
// Pre-populate
model['setData']([{ id: '1', text: 'Existing', isCompleted: false }]);
await model.create({ text: 'New todo', isCompleted: false });
// Should be rolled back to before the create
const data = model.data$.peek();
expect(data).toHaveLength(1);
expect(data?.[0].text).toBe('Existing');
model.dispose();
});
});Testing State Transitions
.subscribe() only fires on future changes, not the value present when you subscribe — keep that in mind when asserting a sequence:
it('sets loading=true then loading=false during fetch', async () => {
const loadingStates: boolean[] = [];
mockFetcher.mockResolvedValue([]);
const model = createModel();
const unsubscribe = model.isLoading$.subscribe(v => loadingStates.push(v));
await model.fetch();
unsubscribe();
expect(loadingStates).toEqual([true, false]); // during, then after — no replay of the initial false
model.dispose();
});Dos and Don'ts
Do: Keep Models Free of UI Concerns
// ✅ Good — model owns data, ViewModel derives display state
class TaskModel extends RestfulApiModel<Task[], typeof TaskListSchema> {
constructor() { super({ ...config }); }
}
class TaskListViewModel extends BaseViewModel<TaskModel> {
readonly incompleteTasks$ = computed(() => (this.data$.get() ?? []).filter((t) => !t.done));
}// ❌ Bad — model knows about UI concern (filter mode)
class TaskModel extends RestfulApiModel<Task[], typeof TaskListSchema> {
filterMode = 'all'; // ← belongs in ViewModel or Store
get visibleTasks() { ... } // ← belongs in ViewModel
}Do: Use a Single Source of Truth Per Domain
// ✅ Good — one AuthModel shared across ViewModels
class AppViewModel extends BaseViewModel {
readonly auth = container.get(AuthModel); // singleton from DI container
}
class ProfileViewModel extends BaseViewModel {
readonly auth = container.get(AuthModel); // same instance
}// ❌ Bad — each ViewModel creates its own model instance
class AppViewModel { auth = new AuthModel(); }
class ProfileViewModel { auth = new AuthModel(); } // different instances, no shared stateDo: Put Business Data in Models, UI State in Store
// ✅ Good
class TaskModel extends BaseModel<Task[], ...> { /* owns task list */ }
const uiStore = createStore({ sidebarOpen: false, activeTab: 'all' }); // UI only// ❌ Bad
const store = createStore({
tasks: [], // ← business data, not UI state
sidebarOpen: false
});Do: Always Validate API Responses
// ✅ Good — schema catches silent API contract breaks early
const model = new RestfulApiModel({
...config,
schema: UserListSchema,
validateSchema: true,
});// ❌ Bad — without schema, a changed API field silently corrupts your data
const model = new RestfulApiModel({ ...config, schema: UserSchema, validateSchema: false });Do: Dispose Models You Construct Internally
// ✅ Good — ViewModel owns the Model it created, so it disposes it explicitly
class UserViewModel extends BaseViewModel<UserModel> {
constructor() {
super(new UserModel());
}
dispose() {
super.dispose(); // registered commands + addSubscription teardowns
this.model.dispose();
}
}
// In the View (React)
useEffect(() => {
vm.fetchCommand.execute();
return () => vm.dispose();
}, []);// ❌ Bad — super.dispose() alone never touches the Model; setData/setLoading/
// setError silently keep working forever, and nothing marks it disposed
class UserViewModel extends BaseViewModel<UserModel> {
constructor() {
super(new UserModel());
}
// no dispose() override — model.dispose() never called
}Don't: Call setData/setLoading/setError from the ViewModel
These are protected methods for the Model to manage its own internals. ViewModels should only read observables and call model methods.
// ❌ Bad
class UserViewModel extends BaseViewModel<UserModel> {
loadUser() {
this.model.setLoading(true); // ← Model should manage its own loading
this.model.setData(user); // ← Model should control its own data
}
}// ✅ Good
class UserViewModel extends BaseViewModel<UserModel> {
readonly loadCommand = this.registerCommand(
new Command(async () => {
await this.model.fetch(); // model handles loading + error internally
}),
);
}Don't: Derive Computed State in the Model
// ❌ Bad — formatting and filtering belong in the ViewModel
class TaskModel extends RestfulApiModel<Task[], ...> {
get formattedTasks() {
return this.getCurrentData()?.map(t => ({
...t,
label: `${t.title} (${t.done ? 'done' : 'pending'})`,
}));
}
}// ✅ Good — ViewModel computes derived/formatted values
class TaskViewModel extends BaseViewModel<TaskModel> {
readonly formattedTasks$ = computed(() =>
(this.data$.get() ?? []).map((t) => ({
...t,
label: `${t.title} (${t.done ? 'done' : 'pending'})`,
})),
);
}Choosing the Right Model
BaseModel— local-only state, WebSocket feeds, in-memory collections, anything that isn't RESTRestfulApiModel— standard CRUD resources where you want optimistic updates out of the box- Read-heavy data shared across multiple ViewModels? See QueryStateModel (internal, not yet exported) above for the current recommended approach.
Where to Go Next
- ViewModels — how ViewModels consume Model signals, derive state, and expose commands
- Signals Core — the
signal/computed/effectprimitives underlying all of this - Query Core — caching and stale-while-revalidate with
@web-loom/query-core