Web Loom logo
Chapter 03Foundations

MVVM Pattern Fundamentals

Chapter 3: MVVM Pattern Fundamentals

Before we dive into building framework-agnostic architecture, we need to establish a solid understanding of the MVVM pattern itself. MVVM isn't just a pattern—it's a philosophy about how we structure frontend applications to maximize testability, maintainability, and flexibility. In this chapter, we'll dissect MVVM's core components, understand why each layer exists, and see how this separation of concerns directly addresses the architectural problems we identified in Chapter 1.

3.1 Understanding the Three Layers

MVVM divides your application into three distinct layers: Model, View, and ViewModel. Each layer has a specific responsibility, and the boundaries between them aren't negotiable. Let's examine each layer and understand why these boundaries matter.

The Model Layer: Your Application's Truth

The Model represents your application's domain logic and data structures. It's framework-agnostic, UI-agnostic, and should know absolutely nothing about how data gets displayed.

Think of the Model as your application's source of truth. It contains:

  • Domain data: The core business objects and their state
  • Business rules: Logic that's true regardless of how you display it
  • Data validation: Ensuring data integrity with schemas
  • State management: Reactive observables for data changes

Here's a critical point: your Model shouldn't depend on your View or ViewModel. This unidirectional dependency flow is what makes MVVM testable. You can validate your business logic without ever rendering a UI component.

Let's look at the actual BaseModel implementation from the Web Loom monorepo:

// packages/mvvm-core/src/models/BaseModel.ts
import { BehaviorSubject, Observable } from 'rxjs';
import { ZodSchema } from 'zod';
 
export interface IBaseModel<TData, TSchema extends ZodSchema<TData>> {
  readonly data$: Observable<TData | null>;
  readonly isLoading$: Observable<boolean>;
  readonly error$: Observable<any>;
  readonly schema?: TSchema;
 
  setData(newData: TData | null): void;
  setLoading(status: boolean): void;
  setError(err: any): void;
  clearError(): void;
  validate(data: any): TData;
}
 
export class BaseModel<TData, TSchema extends ZodSchema<TData>> 
  implements IBaseModel<TData, TSchema> {
  
  protected _data$ = new BehaviorSubject<TData | null>(null);
  public readonly data$: Observable<TData | null> = this._data$.asObservable();
 
  protected _isLoading$ = new BehaviorSubject<boolean>(false);
  public readonly isLoading$: Observable<boolean> = this._isLoading$.asObservable();
 
  protected _error$ = new BehaviorSubject<any>(null);
  public readonly error$: Observable<any> = this._error$.asObservable();
 
  public readonly schema?: TSchema;
 
  constructor(input: { initialData?: TData | null; schema?: TSchema }) {
    const { initialData = null, schema } = input;
    if (initialData !== null) {
      this._data$.next(initialData);
    }
    this.schema = schema;
  }
 
  public setData(newData: TData | null): void {
    this._data$.next(newData);
  }
 
  public setLoading(status: boolean): void {
    this._isLoading$.next(status);
  }
 
  public setError(err: any): void {
    this._error$.next(err);
  }
 
  public clearError(): void {
    this._error$.next(null);
  }
 
  public validate(data: any): TData {
    if (!this.schema) {
      console.warn('No Zod schema provided. Validation will not occur.');
      return data as TData;
    }
    return this.schema.parse(data);
  }
 
  public dispose(): void {
    this._data$.complete();
    this._isLoading$.complete();
    this._error$.complete();
  }
}

Notice several key characteristics of this Model:

Reactive State with RxJS: The Model uses BehaviorSubject to manage state reactively. Any component that subscribes to data$, isLoading$, or error$ will automatically receive updates when state changes.

Framework Independence: This is pure TypeScript with RxJS. There's no React, Vue, or Angular code here. The Model works with any framework—or no framework at all.

Validation with Zod: The Model accepts a Zod schema for type-safe validation. When data is validated, Zod ensures it matches the expected structure and types.

Lifecycle Management: The dispose() method completes all observables, preventing memory leaks when the Model is no longer needed.

This is the foundation of MVVM in the GreenWatch system. Every domain entity—sensors, greenhouses, readings, alerts—extends this BaseModel to get reactive state management and validation for free.

The View Layer: Presentation Only

The View is responsible for rendering UI and capturing user input. That's it. In MVVM, the View should be as dumb as possible—it displays what the ViewModel tells it to display and forwards user actions to the ViewModel.

Your View should never contain business logic. If you're writing an if statement in your View that makes a business decision, you're doing it wrong. That logic belongs in the ViewModel or Model.

Here's what a proper MVVM View looks like in React, taken from the GreenWatch application:

// apps/mvvm-react/src/components/SensorReadingList.tsx
import { useEffect } from 'react';
import { sensorReadingViewModel, type SensorReadingListData } 
  from '@repo/view-models/SensorReadingViewModel';
import { useObservable } from '../hooks/useObservable';
 
export function SensorReadingList() {
  const readingList = useObservable(
    sensorReadingViewModel.data$, 
    [] as SensorReadingListData
  );
 
  useEffect(() => {
    const fetchData = async () => {
      await sensorReadingViewModel.fetchCommand.execute();
    };
    fetchData();
  }, []);
 
  return (
    <div className="card">
      <h1 className="card-title">Sensor Readings</h1>
      {readingList && readingList.length > 0 ? (
        <ul className="card-content list">
          {readingList.map((reading) => (
            <li key={reading.id + reading.timestamp} className="list-item">
              Reading ID: {reading.sensorId}, 
              Timestamp: {new Date(reading.timestamp).toLocaleString()}, 
              Value: {reading.value}
            </li>
          ))}
        </ul>
      ) : (
        <p>No sensor readings found or still loading...</p>
      )}
    </div>
  );
}

See what's happening here? The View doesn't know anything about:

  • How to fetch sensor readings from the API
  • How to format or transform the data
  • What business rules apply to sensor readings
  • How to handle errors or loading states

It just subscribes to the ViewModel's data$ observable using the useObservable hook and renders what it receives. When the user wants to fetch data, it calls viewModel.fetchCommand.execute(). That's it.

The View is a pure function of ViewModel state. This is declarative UI at its finest.

The ViewModel Layer: The Bridge

The ViewModel is where MVVM's power becomes apparent. It sits between the Model and View, transforming raw domain data into presentation-ready formats and translating user actions into domain operations.

Your ViewModel has several responsibilities:

  1. State Management: Holds UI-specific state (loading flags, validation errors, etc.)
  2. Data Transformation: Converts Model data into display formats
  3. Command Handling: Processes user actions and coordinates with the Model
  4. Presentation Logic: Determines what to show and when

Let's look at the actual BaseViewModel implementation from Web Loom:

// packages/mvvm-core/src/viewmodels/BaseViewModel.ts
import { Observable, Subject, Subscription } from 'rxjs';
import { map, startWith, takeUntil } from 'rxjs/operators';
import { ZodError } from 'zod';
import type { BaseModel } from '../models/BaseModel';
 
export class BaseViewModel<TModel extends BaseModel<any, any>> {
  protected readonly _subscriptions = new Subscription();
  protected readonly _destroy$ = new Subject<void>();
 
  // Expose observables directly from the injected model
  public readonly data$: Observable<TModel['data']>;
  public readonly isLoading$: Observable<boolean>;
  public readonly error$: Observable<any>;
  public readonly validationErrors$: Observable<ZodError | null>;
  
  protected readonly model: TModel;
 
  constructor(model: TModel) {
    this.model = model;
    if (!model) {
      throw new Error('BaseViewModel requires an instance of BaseModel.');
    }
 
    // Expose model observables with automatic cleanup
    this.data$ = this.model.data$.pipe(takeUntil(this._destroy$));
    this.isLoading$ = this.model.isLoading$.pipe(takeUntil(this._destroy$));
    this.error$ = this.model.error$.pipe(takeUntil(this._destroy$));
 
    // Extract Zod validation errors from the error stream
    this.validationErrors$ = this.model.error$.pipe(
      map((err) => (err instanceof ZodError ? err : null)),
      startWith(null),
      takeUntil(this._destroy$),
    );
  }
 
  protected addSubscription(subscription: Subscription): void {
    this._subscriptions.add(subscription);
  }
 
  public dispose(): void {
    this._destroy$.next();
    this._destroy$.complete();
    this._subscriptions.unsubscribe();
  }
}

This BaseViewModel demonstrates several critical MVVM principles:

Model Connection: The ViewModel receives a Model instance in its constructor and exposes the Model's observables to Views. This creates a clean separation—the ViewModel doesn't own the data, it just provides access to it.

Reactive Streams: The ViewModel uses RxJS operators like takeUntil to automatically clean up subscriptions when the ViewModel is disposed. This prevents memory leaks.

Validation Error Handling: The ViewModel extracts Zod validation errors from the Model's error stream and exposes them separately. This makes it easy for Views to display validation errors distinctly from other errors.

Lifecycle Management: The dispose() method ensures all subscriptions are cleaned up when the ViewModel is no longer needed.

Here's a concrete example from GreenWatch—the SensorViewModel:

// 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);
  }
}
 
const sensorModel = new SensorModel();
export const sensorViewModel = new SensorViewModel(sensorModel);

The SensorViewModel extends RestfulApiViewModel, which adds CRUD operations (create, read, update, delete) on top of BaseViewModel. This demonstrates the power of composition—we build specialized ViewModels by extending base classes with additional capabilities.

3.2 Data Flow in MVVM

Understanding how data flows through MVVM is crucial. Let's trace a complete cycle from user action to UI update using the GreenWatch sensor reading example.

The Request Flow: User Action → Model Update

  1. User interacts with View: Clicks to fetch sensor readings
  2. View calls ViewModel method: sensorReadingViewModel.fetchCommand.execute()
  3. ViewModel coordinates with Model: The command triggers the Model's fetch operation
  4. Model updates loading state: Emits true through isLoading$
  5. Model fetches data: Makes HTTP request to the API
  6. Model validates data: Uses Zod schema to validate the response
  7. Model updates data state: Emits new data through data$
  8. Model updates loading state: Emits false through isLoading$

The Response Flow: Model Update → UI Update

This is where reactive programming shines:

  1. Model state changes: New sensor readings added to data$
  2. ViewModel observables emit: The data$ observable emits the new data
  3. View subscription receives update: The useObservable hook receives the new data
  4. React re-renders: React updates the DOM with new sensor readings

Here's the critical insight: the View never explicitly subscribes to Model changes or manually updates the DOM. The reactive binding system handles this automatically through RxJS observables. This is why MVVM scales so well—adding new data sources doesn't require touching the View code.

Unidirectional Data Flow

MVVM enforces unidirectional data flow:

  • View → ViewModel: The View calls ViewModel methods (commands)
  • ViewModel → Model: The ViewModel updates Model state
  • Model → ViewModel → View: Data flows back through observables

This unidirectional flow makes the application predictable and debuggable. You always know where state changes originate and how they propagate through the system.

3.3 The Benefits of Layer Separation

Now that we've seen how MVVM works, let's discuss why this separation matters. These aren't theoretical benefits—they directly address the pain points we identified in Chapter 1.

Testability: Test Without the UI

The most immediate benefit is testability. Because your ViewModel doesn't depend on framework-specific UI components, you can test it in a pure Node.js environment with standard testing tools.

With the GreenWatch ViewModels, you can test:

  • Observable emissions and reactive state updates
  • Data transformation logic
  • Command execution and error handling
  • Validation with Zod schemas

All without ever rendering a component. Compare this to testing presentation logic embedded in React components—you'd need to set up a rendering environment, mock browser APIs, and navigate framework-specific testing utilities. With MVVM, your tests are faster, more focused, and framework-agnostic.

Maintainability: Change One Layer at a Time

MVVM's clear boundaries make code changes safer and more predictable. Let's say you need to change how sensor readings are formatted. In a tightly coupled architecture, this might touch multiple files and risk breaking unrelated functionality.

With MVVM, you change exactly one place—the ViewModel's data transformation logic. Your View doesn't change. Your Model doesn't change. The reactive system automatically propagates the update. This is the power of separation of concerns—changes are localized to the layer that owns the concern.

Framework Independence: Swap Frameworks Without Rewriting Logic

Here's where MVVM truly shines for modern frontend development. Your ViewModel and Model are pure TypeScript with RxJS—they work with React, Vue, Angular, Lit, or even vanilla JavaScript.

The SensorViewModel we saw earlier works identically in:

  • React (with useObservable hook)
  • Vue (with watchEffect composable)
  • Angular (with async pipe)
  • Lit (with reactive controllers)
  • Vanilla JS (with direct subscriptions)

The exact same ViewModel works in all these frameworks. We didn't rewrite any logic, business rules, or data transformation code. We only changed the View layer—the framework-specific rendering code.

This isn't a theoretical exercise. In real-world applications, you might need to:

  • Migrate from one framework to another as your framework of choice evolves
  • Build a mobile app that shares logic with your web app
  • Create a desktop app that reuses your web ViewModels

With MVVM, these scenarios become feasible. Your core logic is framework-agnostic, so you're not locked into a single ecosystem.

3.4 Common MVVM Misconceptions

Before we move forward, let's address some common misconceptions about MVVM that might be holding you back.

"MVVM is Too Much Boilerplate"

You'll hear developers complain that MVVM creates excessive boilerplate compared to collocating everything in components. This criticism misses the point entirely.

Yes, MVVM requires creating separate ViewModel files. Yes, you're writing more files than if you stuffed everything into a React component. But here's what you're gaining:

  • Testable code that doesn't require a rendering environment
  • Reusable logic that works across frameworks and platforms
  • Clear boundaries that prevent business logic from leaking into Views
  • Maintainable architecture that scales with team size

The "boilerplate" is actually structure. It's the scaffolding that keeps your application from collapsing under its own weight as it grows.

If you're building a small prototype that you'll throw away next month, fine—skip MVVM. But if you're building a production application that'll be maintained by a team for years, this structure is essential.

"Reactive Programming is Too Complex"

Some developers see RxJS operators like takeUntil, map, and combineLatest and think it's overcomplicated. They'd rather use simple imperative code with manual state updates.

This is short-term thinking. RxJS has a learning curve, but once you understand it, it makes your code significantly simpler. Without reactive programming, you'd need to:

  1. Manually track all state changes
  2. Manually notify all interested Views
  3. Handle race conditions if multiple updates happen simultaneously
  4. Write complex cleanup logic to prevent memory leaks

With RxJS, you update the BehaviorSubject and everything else happens automatically through the observable pipeline. The complexity is managed by a well-tested reactive system instead of scattered throughout your codebase.

"ViewModels Should Be Framework-Specific"

Some developers think ViewModels should use framework-specific patterns—React hooks, Vue composition API, Angular services. This defeats the entire purpose of MVVM.

Your ViewModel should be pure TypeScript with RxJS. If it depends on framework APIs, you've created tight coupling and lost framework independence.

The View layer is where you integrate framework-specific code. Use React hooks in your View components if you need them, but keep the ViewModel framework-agnostic.

3.5 MVVM in the GreenWatch System

Let's see how MVVM applies to the GreenWatch greenhouse monitoring system. The domain model includes:

  • Greenhouse: Container entity with environmental zones
  • Sensor: Device that measures environmental conditions (temperature, humidity, soil moisture)
  • SensorReading: Time-series data point from a sensor
  • ThresholdAlert: Alert triggered when readings exceed configured thresholds

Each of these domain entities has:

  • A Model (extending BaseModel) that manages domain data and business rules
  • A ViewModel (extending BaseViewModel or RestfulApiViewModel) that provides presentation logic
  • Views in React, Vue, Angular, Lit, and Vanilla JS that render the UI

The same ViewModels work across all five framework implementations. This demonstrates the power of MVVM—write your business logic once, use it everywhere.

In the next chapters, we'll dive deeper into building Models and ViewModels, exploring reactive state management, and implementing Views in different frameworks. But first, you need to understand these fundamentals—the three layers, the data flow, and the benefits of separation of concerns.

Summary

MVVM provides a robust architectural foundation for modern frontend applications through its clear separation of concerns:

  • The Model contains domain logic and data, completely independent of UI frameworks. It uses RxJS observables for reactive state management and Zod for validation.

  • The View renders UI declaratively based on ViewModel state. It subscribes to ViewModel observables and calls ViewModel methods, but contains no business logic.

  • The ViewModel bridges the gap, exposing Model observables to Views and handling user interactions. It's framework-agnostic and testable without UI dependencies.

This separation delivers concrete benefits: ViewModels are testable without UI dependencies, business logic is maintainable in isolation, and your core logic works across any framework or platform. RxJS provides the reactive glue that makes this architecture elegant and scalable.

In the next chapter, we'll start building framework-agnostic Models, exploring how to implement domain logic, validation, and API operations using the BaseModel and RestfulApiModel patterns from Web Loom.

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