Web Loom logo
Chapter 05Core Patterns

ViewModels and Reactive State

Chapter 5: ViewModels and Reactive State

In the previous chapter, we explored how Models encapsulate domain logic and data in a framework-agnostic way. But Models alone don't solve the presentation problem—they don't know how to format data for display, manage UI state, or coordinate user interactions. That's where ViewModels come in.

The ViewModel is the presentation logic layer in MVVM architecture. It sits between your Model (domain logic) and your View (UI), transforming domain data into view-ready state and translating user actions into domain operations. Most importantly, ViewModels are framework-agnostic—the same ViewModel can power a React component, a Vue component, an Angular component, or even vanilla JavaScript.

In this chapter, we'll explore the ViewModel layer using real implementations from the GreenWatch greenhouse monitoring system. You'll see how ViewModels manage reactive state with @web-loom/signals-core signals, handle lifecycle concerns, and provide a clean contract for Views to consume.

5.1 The ViewModel's Responsibilities

Before diving into implementation, let's be clear about what ViewModels do—and what they don't do.

ViewModels ARE responsible for:

  • Presentation Logic: Formatting data for display, computing derived values, managing UI-specific state (like "is this panel expanded?")
  • User Action Coordination: Translating button clicks and form submissions into Model operations
  • State Exposure: Providing signals that Views can read reactively
  • Lifecycle Management: Cleaning up subscriptions and resources when no longer needed

ViewModels are NOT responsible for:

  • Domain Logic: Business rules, validation, and data persistence belong in Models
  • UI Rendering: ViewModels don't know about DOM, JSX, templates, or framework-specific rendering
  • Direct User Interaction: ViewModels don't handle click events or keyboard input—Views do that and call ViewModel methods

This separation is crucial. A well-designed ViewModel can be tested without any UI framework, and the same ViewModel can be used across multiple frameworks without modification.

Let's see this in practice with GreenWatch's SensorViewModel.

5.2 BaseViewModel: The Foundation

All ViewModels in the GreenWatch system extend from BaseViewModel, which provides core functionality for connecting to Models and managing reactive state. Let's examine the implementation:

// packages/mvvm-core/src/viewmodels/BaseViewModel.ts
import { computed, type ReadonlySignal } from '@web-loom/signals-core';
import { ZodError } from 'zod';
import type { BaseModel, IDisposable } from '../models/BaseModel';
import type { ICommand } from '../commands/Command';
 
export type Teardown = () => void;
 
export class BaseViewModel<TModel extends BaseModel<any, any>> {
  private readonly _teardowns: Teardown[] = [];
  private readonly _registeredCommands: ICommand<any, any>[] = [];
 
  // Expose signals directly from the injected model
  public readonly data$: ReadonlySignal<TModel['data']>;
  public readonly isLoading$: ReadonlySignal<boolean>;
  public readonly error$: ReadonlySignal<any>;
  public readonly validationErrors$: ReadonlySignal<ZodError | null>;
  
  protected readonly model: TModel;
 
  constructor(model: TModel) {
    this.model = model;
    if (!model) {
      throw new Error('BaseViewModel requires an instance of BaseModel in its constructor.');
    }
 
    // Re-expose the model's own signals directly — same instances, no wrapping
    this.data$ = this.model.data$;
    this.isLoading$ = this.model.isLoading$;
    this.error$ = this.model.error$;
 
    // Derive validation errors from the error signal
    this.validationErrors$ = computed(() => {
      const err = this.model.error$.get();
      return err instanceof ZodError ? err : null;
    });
  }
 
  protected addSubscription(teardown: Teardown): void {
    this._teardowns.push(teardown);
  }
 
  protected registerCommand<TParam, TResult>(
    command: ICommand<TParam, TResult>
  ): ICommand<TParam, TResult> {
    this._registeredCommands.push(command);
    return command;
  }
 
  public dispose(): void {
    // Dispose all registered commands
    this._registeredCommands.forEach((cmd) => {
      if (this.isDisposable(cmd)) {
        cmd.dispose();
      }
    });
    this._registeredCommands.length = 0;
 
    // Run every teardown registered via addSubscription()
    this._teardowns.forEach((teardown) => teardown());
    this._teardowns.length = 0;
  }
 
  private isDisposable(obj: any): obj is IDisposable {
    return obj && typeof obj.dispose === 'function';
  }
}

Let's break down the key patterns here:

5.2.1 Signal Exposure

The ViewModel exposes four core signals that Views can read reactively:

  • data$: The current domain data from the Model
  • isLoading$: Loading state for showing spinners or disabling UI
  • error$: Any errors that occurred during operations
  • validationErrors$: Zod validation errors derived from the error signal

These signals are derived from the Model's signals, not duplicated. The ViewModel doesn't maintain its own copy of the data—data$/isLoading$/error$ are literally the same signal instances the Model owns, so it simply provides a clean interface to the Model's reactive state.

5.2.2 No Manual Completion Needed

Unlike RxJS observables, signals don't need an explicit "close this stream" operator. There's nothing analogous to takeUntil here: data$/isLoading$/error$ are just re-exposed signal references, so there's nothing about them to tear down when the ViewModel disposes. What does need lifecycle tracking is anything you subscribe to by hand — that's what addSubscription() (below) is for.

5.2.3 Subscription Management

The _teardowns array collects any manual subscriptions the ViewModel sets up by hand (via sig.subscribe() or observe()). When dispose() is called, every registered teardown runs:

protected addSubscription(teardown: Teardown): void {
  this._teardowns.push(teardown);
}
 
public dispose(): void {
  this._teardowns.forEach((teardown) => teardown());
  // ... other cleanup
}

This ensures that ViewModels don't leak memory even in long-running applications. Note that computed() values — like validationErrors$ above — need no such registration at all; they have no subscription to leak.

5.2.4 Command Registration

Commands (which we'll explore in detail in Chapter 7) are also registered for automatic disposal:

protected registerCommand<TParam, TResult>(
  command: ICommand<TParam, TResult>
): ICommand<TParam, TResult> {
  this._registeredCommands.push(command);
  return command;
}

This pattern ensures that all resources—observables, subscriptions, and commands—are properly cleaned up when the ViewModel is no longer needed.

5.3 RestfulApiViewModel: CRUD Operations

Many ViewModels need to perform CRUD (Create, Read, Update, Delete) operations against a REST API. Rather than implementing these operations in every ViewModel, the GreenWatch system provides RestfulApiViewModel as a base class:

// packages/mvvm-core/src/viewmodels/RestfulApiViewModel.ts
import { signal, computed, type ReadonlySignal } from '@web-loom/signals-core';
import { RestfulApiModel } from '../models/RestfulApiModel';
import { Command } from '../commands/Command';
import { ZodSchema } from 'zod';
 
type ItemWithId = { id: string; [key: string]: any };
type ExtractItemType<T> = T extends (infer U)[] ? U : T;
 
export class RestfulApiViewModel<TData, TSchema extends ZodSchema<TData>> {
  protected model: RestfulApiModel<TData, TSchema>;
 
  public readonly data$: ReadonlySignal<TData | null>;
  public readonly isLoading$: ReadonlySignal<boolean>;
  public readonly error$: ReadonlySignal<any>;
 
  // Commands for CRUD operations
  public readonly fetchCommand: Command<string | string[] | void, void>;
  public readonly createCommand: Command<Partial<ExtractItemType<TData>> | Partial<ExtractItemType<TData>>[], void>;
  public readonly updateCommand: Command<{ id: string; payload: Partial<ExtractItemType<TData>> }, void>;
  public readonly deleteCommand: Command<string, void>;
 
  // Selection state for list views
  public readonly selectedItem$: ReadonlySignal<ExtractItemType<TData> | null>;
  protected readonly _selectedItemId = signal<string | null>(null);
 
  constructor(model: RestfulApiModel<TData, TSchema>) {
    if (!(model instanceof RestfulApiModel)) {
      throw new Error('RestfulApiViewModel requires an instance of RestfulApiModel.');
    }
    this.model = model;
 
    this.data$ = this.model.data$;
    this.isLoading$ = this.model.isLoading$;
    this.error$ = this.model.error$;
 
    // Initialize Commands
    this.fetchCommand = new Command(async (id: string | string[] | void) => {
      const ids = Array.isArray(id) ? id : id ? [id] : undefined;
      await this.model.fetch(ids);
    });
 
    this.createCommand = new Command(
      async (payload: Partial<ExtractItemType<TData>> | Partial<ExtractItemType<TData>>[]) => {
        await this.model.create(payload);
      },
    );
 
    this.updateCommand = new Command(
      async ({ id, payload }: { id: string; payload: Partial<ExtractItemType<TData>> }) => {
        await this.model.update(id, payload);
      },
    );
 
    this.deleteCommand = new Command(async (id: string) => {
      await this.model.delete(id);
    });
 
    // Compute selected item from data and selection state — auto-tracks
    // both this.model.data$ and this._selectedItemId
    this.selectedItem$ = computed(() => {
      const data = this.model.data$.get();
      const selectedId = this._selectedItemId.get();
      if (Array.isArray(data) && selectedId) {
        const itemWithId = data.find((item: unknown): item is ItemWithId => {
          return (
            typeof item === 'object' &&
            item !== null &&
            'id' in item &&
            typeof (item as any).id === 'string' &&
            (item as any).id === selectedId
          );
        });
        return (itemWithId as ExtractItemType<TData>) || null;
      }
      return null;
    });
  }
 
  public selectItem(id: string | null): void {
    this._selectedItemId.set(id);
  }
 
  public dispose(): void {
    this.model.dispose();
    this.fetchCommand.dispose();
    this.createCommand.dispose();
    this.updateCommand.dispose();
    this.deleteCommand.dispose();
  }
}

This ViewModel provides several key features:

5.3.1 Command Pattern for Operations

Instead of exposing methods like fetch(), create(), update(), and delete(), the ViewModel exposes Commands. Commands are objects that encapsulate an operation and can be executed, disabled, or monitored:

public readonly fetchCommand: Command<string | string[] | void, void>;
public readonly createCommand: Command<Partial<ExtractItemType<TData>> | Partial<ExtractItemType<TData>>[], void>;

Views can execute commands and subscribe to their state:

// In a React component
<button 
  onClick={() => viewModel.fetchCommand.execute()}
  disabled={viewModel.fetchCommand.isExecuting}
>
  Fetch Data
</button>

We'll explore Commands in depth in Chapter 7.

5.3.2 Selection State Management

For list views, the ViewModel manages selection state:

public readonly selectedItem$: ReadonlySignal<ExtractItemType<TData> | null>;
protected readonly _selectedItemId = signal<string | null>(null);
 
public selectItem(id: string | null): void {
  this._selectedItemId.set(id);
}

The selectedItem$ signal is computed from the data and selection ID. computed() auto-tracks every signal it reads inside its function body, so it recomputes whenever either data$ or _selectedItemId changes — no manual composition operator needed. This is a powerful pattern for derived state—the ViewModel doesn't store the selected item directly, it computes it on demand.

5.4 Real-World Example: SensorViewModel

Now let's see how these patterns come together in a real ViewModel from the GreenWatch system. The SensorViewModel manages the list of sensors in a greenhouse:

// packages/view-models/src/SensorViewModel.ts
import { RestfulApiViewModel } from '@web-loom/mvvm-core';
import { SensorListSchema, type SensorListData, SensorModel } from '@repo/models';
 
export class SensorViewModel extends RestfulApiViewModel<SensorListData, typeof SensorListSchema> {
  constructor(model: SensorModel) {
    super(model);
  }
}
 
// Create a singleton instance for the application
const sensorModel = new SensorModel();
export const sensorViewModel = new SensorViewModel(sensorModel);
export type { SensorListData };

This is remarkably simple because RestfulApiViewModel provides all the CRUD functionality. The SensorViewModel just needs to:

  1. Extend RestfulApiViewModel with the correct types
  2. Pass the SensorModel to the base class constructor

The ViewModel now exposes:

  • data$: Signal of sensor list data
  • isLoading$: Loading state
  • error$: Error state
  • fetchCommand: Command to fetch sensors
  • createCommand: Command to create a new sensor
  • updateCommand: Command to update a sensor
  • deleteCommand: Command to delete a sensor
  • selectedItem$: Currently selected sensor
  • selectItem(id): Method to select a sensor

All of this functionality is framework-agnostic. The same SensorViewModel can be used in React, Vue, Angular, Lit, or vanilla JavaScript. We'll see exactly how in Chapters 8-12.

5.5 Advanced Example: GreenHouseViewModel

Some ViewModels need more customization. The GreenHouseViewModel uses a factory pattern to create ViewModels with specific configurations:

// packages/view-models/src/GreenHouseViewModel.ts
import { createReactiveViewModel, type ViewModelFactoryConfig } from '@web-loom/mvvm-core';
import { greenHouseConfig } from '@repo/models';
import { type GreenhouseListData, GreenhouseListSchema, type GreenhouseData } from '@repo/models';
 
type TConfig = ViewModelFactoryConfig<GreenhouseListData, typeof GreenhouseListSchema>;
 
const config: TConfig = {
  modelConfig: greenHouseConfig,
  schema: GreenhouseListSchema,
};
 
export const greenHouseViewModel = createReactiveViewModel(config);
 
export type { GreenhouseListData, GreenhouseData };

This demonstrates an alternative approach: instead of manually instantiating Models and ViewModels, we use a factory function (createReactiveViewModel) that creates both from a configuration object. This pattern is useful when you have many similar ViewModels and want to reduce boilerplate.

The key insight is that both approaches—manual instantiation and factory creation—produce the same result: a framework-agnostic ViewModel that exposes reactive state through signals.

5.6 Reactive State with Signals

You've seen signals throughout this chapter, but let's be explicit about why @web-loom/signals-core is the foundation of reactive state in the GreenWatch MVVM implementation.

5.6.1 Why Signals?

Signals provide several critical capabilities for ViewModels:

1. Synchronous, Pull-Based Reactivity: Signals hold a current value you can always read synchronously with .get()/.peek(), and notify subscribers when that value changes. When the Model's data changes, all subscribed Views automatically receive the update. No polling, no manual refresh.

2. Composability: computed() lets you derive new signals from existing ones declaratively, auto-tracking every signal read inside its function body. The selectedItem$ signal we saw earlier is a perfect example—it's composed from data$ and _selectedItemId.

3. Simplicity: There's no operator library to learn. signal() for state, computed() for derivations, effect() for side effects — that's the whole vocabulary. Cleanup is a matter of calling the function .subscribe()/observe() returned, not composing a cancellation operator into a pipeline.

4. Framework Agnostic: @web-loom/signals-core works everywhere—Node.js, browsers, React Native. Your ViewModels aren't tied to any specific UI framework, and the package has zero runtime dependencies.

5.6.2 Writable Signal vs ReadonlySignal

You'll notice that ViewModels use both a private writable signal() and a public ReadonlySignal:

// Internal state uses a writable signal
protected readonly _selectedItemId = signal<string | null>(null);
 
// Public API exposes a ReadonlySignal
public readonly selectedItem$: ReadonlySignal<ExtractItemType<TData> | null>;

This is intentional:

  • A writable signal() is used internally because it holds the current value and allows the ViewModel to update it with .set()
  • A ReadonlySignal (via computed(), or .asReadonly() on a plain signal) is exposed publicly because Views should only read the state, not modify it

This encapsulation ensures that only the ViewModel can change its state. Views are consumers, not producers.

5.6.3 Derived State with computed()

One of the most powerful patterns in reactive ViewModels is derived state—state that's computed from other state:

this.selectedItem$ = computed(() => {
  const data = this.model.data$.get();
  const selectedId = this._selectedItemId.get();
  if (Array.isArray(data) && selectedId) {
    return data.find(item => item.id === selectedId) || null;
  }
  return null;
});

This signal automatically recomputes whenever either data$ or _selectedItemId changes — computed() tracks both because both are read via .get() inside its function body. The ViewModel doesn't need to manually update selectedItem$—it's always in sync because it's derived from the source signals.

This pattern eliminates entire classes of bugs where state gets out of sync. If you've ever had a "selected item" that didn't match the actual data, you know the pain this solves.

5.7 ViewModel Lifecycle

ViewModels have a clear lifecycle that mirrors the lifecycle of the Views that consume them:

1. Creation: ViewModel is instantiated with its Model dependency

const model = new SensorModel();
const viewModel = new SensorViewModel(model);

2. Subscription: Views subscribe to the ViewModel's signals

// In a React component
useEffect(() => {
  return viewModel.data$.subscribe(data => {
    setData(data);
  });
}, []);

3. Interaction: User actions trigger ViewModel methods or commands

<button onClick={() => viewModel.fetchCommand.execute()}>
  Refresh
</button>

4. Disposal: When the View unmounts, the ViewModel is disposed

useEffect(() => {
  return () => viewModel.dispose();
}, []);

The dispose() method is critical. It:

  • Disposes all registered Commands
  • Runs every teardown registered via addSubscription()
  • Prevents memory leaks

Always call dispose() when you're done with a ViewModel. In React, this happens in the cleanup function of useEffect. In Angular, it happens in ngOnDestroy. In Vue, it happens in onUnmounted. We'll see the framework-specific patterns in Chapters 8-12.

5.8 Testing ViewModels

One of the greatest benefits of the MVVM pattern is testability. ViewModels can be tested without any UI framework—they're just TypeScript classes that expose observables.

Here's how you might test the SensorViewModel:

import { SensorViewModel } from './SensorViewModel';
import { SensorModel } from '@repo/models';
 
describe('SensorViewModel', () => {
  let viewModel: SensorViewModel;
  let model: SensorModel;
 
  beforeEach(() => {
    model = new SensorModel();
    viewModel = new SensorViewModel(model);
  });
 
  afterEach(() => {
    viewModel.dispose();
  });
 
  it('exposes data from the model', async () => {
    // Trigger a fetch and read the resulting value synchronously
    await viewModel.fetchCommand.execute();
 
    expect(viewModel.data$.peek()).toBeDefined();
  });
 
  it('exposes loading state', async () => {
    const loadingStates: boolean[] = [];
 
    // sig.subscribe() only fires on future changes, not the current value
    viewModel.isLoading$.subscribe(isLoading => {
      loadingStates.push(isLoading);
    });
 
    await viewModel.fetchCommand.execute();
 
    expect(loadingStates).toEqual([true, false]);
  });
 
  it('manages selection state', async () => {
    // First, load some data
    await viewModel.fetchCommand.execute();
 
    const data = viewModel.data$.peek();
    if (data && data.length > 0) {
      // Select the first item
      viewModel.selectItem(data[0].id);
 
      // selectedItem$ is a computed signal — reading it synchronously
      // reflects the current selection immediately
      expect(viewModel.selectedItem$.peek()).toEqual(data[0]);
    }
  });
 
  it('cleans up on dispose', () => {
    let callCount = 0;
    const unsubscribe = viewModel.data$.subscribe(() => { callCount++; });
 
    viewModel.dispose();
    unsubscribe(); // safe to call after dispose — signals don't error on double-unsubscribe
 
    // Registered commands should be disposed
    expect(() => viewModel.fetchCommand.dispose()).not.toThrow();
  });
});

Notice that these tests don't involve any UI framework. We're testing pure business logic—the ViewModel's ability to manage state, expose observables, and coordinate with the Model.

This is the power of separation of concerns. Your presentation logic is testable without rendering a single component.

5.9 Preparing for Reactive State Patterns

In this chapter, we've focused on how ViewModels use @web-loom/signals-core signals to manage reactive state — this is the actual reactive mechanism mvvm-core is built on, not one option among several. In Chapter 13, we'll go deeper into reactive state management patterns:

  • Signals in depth: signal(), computed(), effect(), batch(), and debouncedSignal() — the full vocabulary
  • RxJS interop: When and how to bridge into RxJS via the optional @web-loom/signals-core/rxjs subpath, for teams integrating with an existing RxJS-based codebase
  • Comparing reactive approaches: How signals relate to other state management patterns you may have used (Zustand, Nanostores, plain Proxies)

The ViewModel pattern we've established here is agnostic to the reactive mechanism in principle. You could implement ViewModels using stores or plain callbacks instead. The key principles remain the same:

  • ViewModels expose state that Views can observe
  • ViewModels provide methods/commands for user actions
  • ViewModels manage lifecycle and cleanup
  • ViewModels are framework-agnostic

Signals are a deliberately small, dependency-free choice for this: a signal()/computed()/effect() vocabulary is enough to cover everything ViewModels in this book need, without requiring an operator library. Understanding when you might still reach for RxJS — genuinely complex async pipelines, integrating with an existing RxJS-based system — will make you a better architect, which is exactly what Chapter 13 covers.

5.10 Key Takeaways

Let's consolidate what we've learned about ViewModels and reactive state:

ViewModel Responsibilities:

  • Presentation logic, not domain logic
  • State exposure through observables
  • User action coordination
  • Lifecycle management

BaseViewModel Pattern:

  • Connects to a Model and re-exposes its signals directly
  • Registers Commands and manual subscriptions for automatic cleanup — no takeUntil operator needed
  • Manages subscriptions and commands
  • Provides dispose() for resource cleanup

RestfulApiViewModel Pattern:

  • Extends BaseViewModel with CRUD operations
  • Exposes Commands for operations
  • Manages selection state for list views
  • Computes derived state with computed()

Reactive State with Signals:

  • Synchronous, pull-based reactivity for automatic updates
  • Composable with computed() — no operator library needed
  • Writable signal() for internal state, ReadonlySignal for public API
  • Derived state eliminates synchronization bugs

Lifecycle Management:

  • Create → Subscribe → Interact → Dispose
  • Always call dispose() when done
  • Framework-specific cleanup in useEffect, ngOnDestroy, onUnmounted

Testing Benefits:

  • ViewModels are testable without UI frameworks
  • Test presentation logic in isolation
  • Verify observable behavior and state management

In the next chapter, we'll explore the View layer contract—how Views consume ViewModels and what responsibilities they have in the MVVM architecture. You'll see how the same ViewModel can power completely different UI implementations across React, Vue, Angular, and more.


Next Steps: Now that you understand ViewModels and reactive state, you're ready to see how Views consume them. Chapter 6 will show you the "dumb view" philosophy and how to build Views that are pure presentation layers with no business logic.

Web Loom logo
Copyright © Web Loom. All rights reserved.