Web Loom logo
MVVM CoreModels

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-core cache) exists in mvvm-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 for data$. Use null for a single resource that hasn't loaded yet, or [] for an empty list.
  • schema — a Zod schema used by validate() to check incoming data at runtime. Pass undefined to skip validation entirely.

Reactive Signals

Every BaseModel exposes three public signals:

  • data$ReadonlySignal<TData | null> holding the current value
  • isLoading$ReadonlySignal<boolean> for loading state
  • error$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) — update data$ with a new value
  • setLoading(isLoading) — toggle the loading flag
  • setError(error) — store a caught error in error$
  • clearError() — reset error$ to null
  • validate(data) — run the Zod schema; throws ZodError on invalid shape

Snapshot Getters

For non-reactive reads (e.g. inside imperative methods):

  • getCurrentData() — synchronous current value of data$
  • getCurrentLoadingStatus() — synchronous current value of isLoading$
  • getCurrentError() — synchronous current value of error$

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 to false to 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/42

Sets 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 null
  • validateSchema: false — skips Zod validation for performance in this case
  • The model itself has no methods beyond what RestfulApiModel provides

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, derive ItemListSchema = 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 state

Do: 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 REST
  • RestfulApiModel — 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/effect primitives underlying all of this
  • Query Core — caching and stale-while-revalidate with @web-loom/query-core
Was this helpful?
Web Loom logo
Copyright © Web Loom. All rights reserved.