Web Loom logo
Chapter 23Real-World Applications

Conclusion and Best Practices

Chapter 23: Conclusion and Best Practices

23.1 The Journey We've Taken

We started this book with a crisis: frontend codebases collapsing under their own weight, business logic tangled with UI code, tests that mock everything, and developers locked into single frameworks. We've come a long way since then.

You've learned how to build framework-agnostic Models that encapsulate business logic, reactive ViewModels that manage presentation state, and framework-specific Views that remain purely presentational. You've seen these patterns applied across React, Vue, Angular, Lit, and vanilla JavaScript—the same ViewModels powering five different implementations.

You've explored supporting patterns: reactive state management with signals and observables, event-driven communication, data fetching and caching strategies, headless UI behaviors, and design systems. You've studied complete applications—GreenWatch's monitoring system and e-commerce workflows—seeing how patterns scale from components to full-featured systems.

Now it's time to synthesize everything into actionable guidance. This chapter distills the core principles, provides decision-making frameworks, and offers best practices for building maintainable MVVM applications. Consider this your field guide for applying MVVM to real-world projects.

23.2 Core MVVM Principles

Before diving into specifics, let's revisit the fundamental principles that make MVVM effective:

Separation of Concerns

The Principle: Each layer has a single, well-defined responsibility.

  • Models handle business logic and data structures
  • ViewModels manage presentation state and user interactions
  • Views render UI and capture user input

Why It Matters: When concerns are separated, changes are localized. Need to change validation rules? Update the Model. Need to add a loading spinner? Update the View. Need to change how data is transformed for display? Update the ViewModel. No layer bleeds into another.

In Practice:

// Good: Clear separation
class SensorViewModel {
  // Presentation logic only
  public readonly data$ = this.model.data$;
  public readonly isLoading$ = this.model.isLoading$;
  
  fetchSensors() {
    this.model.fetch(); // Delegates to Model
  }
}
 
// Bad: Mixed concerns
class SensorComponent {
  async loadSensors() {
    this.loading = true;
    const response = await fetch('/api/sensors'); // API logic in View
    this.sensors = response.json();
    this.loading = false;
  }
}

Framework Independence

The Principle: Business logic should not depend on framework-specific APIs.

ViewModels use framework-agnostic patterns (RxJS observables, plain TypeScript classes) so they work identically across React, Vue, Angular, and any other framework.

Why It Matters: Framework independence provides:

  • Portability: Share logic between web and mobile
  • Longevity: Survive framework migrations without rewrites
  • Testability: Test business logic without framework overhead
  • Team flexibility: Backend developers can contribute to ViewModels

In Practice:

// Good: Framework-agnostic ViewModel
export class GreenHouseViewModel {
  private _data$ = new BehaviorSubject<Greenhouse[]>([]);
  public readonly data$ = this._data$.asObservable();
  
  // Works in React, Vue, Angular, Lit, Vanilla JS
}
 
// Bad: Framework-coupled ViewModel
export class GreenHouseViewModel {
  private [data, setData] = useState<Greenhouse[]>([]); // React-specific
  // Can't use in Vue or Angular
}

Reactive State Management

The Principle: State changes propagate automatically through observable streams.

ViewModels expose observables that Views subscribe to. When state changes, all subscribers receive updates automatically—no manual synchronization required.

Why It Matters: Reactive state eliminates entire classes of bugs:

  • No stale data (Views always reflect current state)
  • No manual update coordination (observables handle it)
  • No forgotten state updates (one source of truth)

In Practice:

// ViewModel exposes observables
public readonly total$ = combineLatest([
  this.subtotal$,
  this.tax$,
  this.shipping$
]).pipe(
  map(([subtotal, tax, shipping]) => subtotal + tax + shipping)
);
 
// View subscribes (React example)
const total = useObservable(cartViewModel.total$, 0);
 
// When subtotal, tax, or shipping changes, total updates automatically

Testability by Design

The Principle: Architecture should make testing easy, not an afterthought.

MVVM's separation of concerns means you can test each layer independently:

  • Test Models without ViewModels or Views
  • Test ViewModels without Views
  • Test Views with mocked ViewModels

Why It Matters: Easy testing leads to:

  • Higher test coverage (tests are cheap to write)
  • Faster test execution (no DOM rendering)
  • More reliable tests (fewer dependencies to mock)
  • Better design (testable code is usually well-designed code)

In Practice:

// Test ViewModel without any framework
describe('CartViewModel', () => {
  it('calculates total correctly', async () => {
    const vm = new CartViewModel();
    vm.addItem({ id: '1', name: 'Product', price: 10, imageUrl: '' });
    
    const total = await firstValueFrom(vm.total$);
    expect(total).toBe(10.80); // 10 + 8% tax
  });
});

Single Source of Truth

The Principle: Each piece of state has exactly one authoritative source.

Don't duplicate state across Models, ViewModels, and Views. State flows in one direction: Model → ViewModel → View. Derived state is computed from source state using observables.

Why It Matters: Multiple sources of truth lead to:

  • Synchronization bugs (state gets out of sync)
  • Increased complexity (which source is correct?)
  • Harder debugging (where did this value come from?)

In Practice:

// Good: Single source, derived values
class CartViewModel {
  private _items$ = new BehaviorSubject<CartItem[]>([]);
  
  // Derived from _items$, not stored separately
  public itemCount$ = this._items$.pipe(
    map(items => items.reduce((sum, item) => sum + item.quantity, 0))
  );
}
 
// Bad: Duplicate state
class CartViewModel {
  private _items: CartItem[] = [];
  private _itemCount: number = 0; // Duplicate! Must be kept in sync manually
  
  addItem(item: CartItem) {
    this._items.push(item);
    this._itemCount++; // Easy to forget, leads to bugs
  }
}

23.3 When to Use MVVM (and When Not To)

MVVM isn't a universal solution. Let's be honest about when it helps and when it's overkill.

Use MVVM When:

1. Business Logic Is Complex

If your application has non-trivial business rules, validation logic, or domain models, MVVM's separation of concerns pays dividends. Examples:

  • E-commerce checkout flows with multi-step validation
  • Financial applications with complex calculations
  • Healthcare systems with regulatory compliance requirements
  • Monitoring dashboards with real-time data processing

2. You Need Framework Portability

If you're building for multiple platforms (web + mobile) or anticipate framework migrations, MVVM's framework independence is invaluable.

3. Testing Is a Priority

If you need high test coverage or fast test execution, MVVM's testability is a major advantage. Testing ViewModels without rendering components is orders of magnitude faster than integration tests.

4. Multiple Teams Collaborate

If backend and frontend teams work together, or if you have specialized UI and business logic developers, MVVM's clear boundaries enable parallel development.

5. State Management Is Non-Trivial

If your application has complex state dependencies, derived state, or cross-component coordination, MVVM's reactive patterns handle this elegantly.

Don't Use MVVM When:

1. The Application Is Truly Simple

If you're building a static marketing site, a simple form, or a basic CRUD interface with no business logic, MVVM is overkill. Use framework-native patterns (React hooks, Vue Composition API) and keep it simple.

2. You're Prototyping

Early-stage prototypes benefit from speed over structure. Don't introduce MVVM until you've validated the concept and are ready to build for production.

3. The Team Lacks Experience

MVVM requires understanding reactive programming, observables, and architectural patterns. If your team is new to these concepts, the learning curve might slow development. Start with simpler patterns and introduce MVVM gradually.

4. Framework-Specific Features Are Critical

If your application heavily relies on framework-specific features (React Suspense, Vue's Teleport, Angular's dependency injection for everything), forcing framework independence might be counterproductive.

5. Performance Is Paramount

MVVM introduces a thin abstraction layer (observables, ViewModels). For most applications, this overhead is negligible. But if you're building a high-performance game, real-time graphics editor, or other performance-critical application, the abstraction might not be worth it.

The Pragmatic Middle Ground

You don't have to go all-in on MVVM. Consider a hybrid approach:

  • Use MVVM for complex features: Checkout flows, dashboards, data-heavy pages
  • Use framework-native patterns for simple features: Static pages, basic forms, simple lists
  • Extract ViewModels incrementally: Start with framework-native code, extract ViewModels when complexity grows

This pragmatic approach lets you apply MVVM where it provides value without forcing it everywhere.

23.4 Architectural Decision-Making

When building MVVM applications, you'll face recurring architectural decisions. Here's guidance for the most common ones.

Decision 1: Class-Based vs Factory-Based ViewModels

Class-Based Approach:

export class SensorViewModel extends RestfulApiViewModel<SensorData, typeof SensorSchema> {
  constructor(model: SensorModel) {
    super(model);
  }
  
  // Add custom methods
  public async calibrateSensor(id: string) {
    // Custom logic
  }
}
 
export const sensorViewModel = new SensorViewModel(new SensorModel());

Factory-Based Approach:

export const sensorViewModel = createReactiveViewModel({
  modelConfig: sensorConfig,
  schema: SensorSchema,
});

When to Use Class-Based:

  • You need custom methods beyond CRUD
  • You want inheritance and polymorphism
  • You need fine-grained control over lifecycle

When to Use Factory-Based:

  • You need standard CRUD operations only
  • You want less boilerplate
  • You prefer composition over inheritance

Recommendation: Start with factory-based for simple ViewModels. Switch to class-based when you need customization.

Decision 2: Singleton vs Instance ViewModels

Singleton Approach:

// One shared instance
export const cartViewModel = new CartViewModel();
 
// All components use the same instance
const items = useObservable(cartViewModel.items$);

Instance Approach:

// Create new instance per component
function ProductList() {
  const [viewModel] = useState(() => new ProductListViewModel());
  
  useEffect(() => {
    return () => viewModel.dispose();
  }, []);
  
  const products = useObservable(viewModel.data$);
}

When to Use Singleton:

  • State should be shared across components (cart, user session, global settings)
  • You want automatic synchronization across the app
  • The ViewModel represents application-level state

When to Use Instance:

  • Each component needs independent state (form state, local filters)
  • You want component-level isolation
  • The ViewModel represents component-level state

Recommendation: Use singletons for application-level state (cart, auth, global data). Use instances for component-level state (forms, local filters, temporary UI state).

Decision 3: RxJS vs Signals vs Other Reactive Libraries

RxJS Observables:

private _data$ = new BehaviorSubject<Data[]>([]);
public readonly data$ = this._data$.asObservable();

Signals (signals-core):

const data = writable<Data[]>([]);
const count = computed(() => data.value.length);

When to Use RxJS:

  • You need complex stream operations (debounce, throttle, combineLatest)
  • You're already using RxJS in your project
  • You need mature ecosystem and extensive operators

When to Use Signals:

  • You want simpler, more intuitive API
  • You don't need complex stream operations
  • You want zero dependencies and smaller bundle size

Recommendation: RxJS for complex applications with sophisticated reactive patterns. Signals for simpler applications or when bundle size matters. Both work with MVVM—choose based on your needs.

Decision 4: Validation in Model vs ViewModel

Model Validation (using Zod):

class SensorModel extends BaseModel<SensorData, typeof SensorSchema> {
  constructor() {
    super(SensorSchema); // Zod schema validates all data
  }
}

ViewModel Validation:

class CheckoutViewModel {
  setShippingAddress(address: ShippingAddress) {
    if (!this.isValidAddress(address)) {
      this._error$.next('Invalid address');
      return;
    }
    this._shippingAddress$.next(address);
  }
}

When to Validate in Model:

  • Data structure validation (types, required fields, formats)
  • Domain invariants (business rules that always apply)
  • API response validation

When to Validate in ViewModel:

  • Presentation-specific validation (form field requirements)
  • Multi-field validation (password confirmation, date ranges)
  • User workflow validation (step-by-step checkout)

Recommendation: Use Model validation for data integrity. Use ViewModel validation for user experience and workflow rules. Both layers can validate—they serve different purposes.

Decision 5: Command Pattern vs Direct Methods

Command Pattern:

public readonly fetchCommand = new Command(async () => {
  await this.model.fetch();
});
 
// Usage
viewModel.fetchCommand.execute();

Direct Methods:

public async fetch() {
  await this.model.fetch();
}
 
// Usage
await viewModel.fetch();

When to Use Command Pattern:

  • You need undo/redo functionality
  • You want to track command execution state (isExecuting, error)
  • You need command queuing or throttling
  • You want a consistent API across all actions

When to Use Direct Methods:

  • You need simple, straightforward method calls
  • You don't need command metadata
  • You prefer less abstraction

Recommendation: Command pattern for complex applications with sophisticated action management. Direct methods for simpler applications where the abstraction isn't needed.

23.5 Best Practices by Layer

Let's organize best practices by architectural layer.

Model Layer Best Practices

1. Keep Models Pure

Models should contain business logic only—no UI concerns, no framework dependencies, no side effects beyond data operations.

// Good: Pure business logic
class SensorModel extends BaseModel<SensorData, typeof SensorSchema> {
  validate(data: any): SensorData {
    return this.schema.parse(data); // Pure validation
  }
}
 
// Bad: UI concerns in Model
class SensorModel {
  showNotification(message: string) { // UI logic doesn't belong here
    toast.success(message);
  }
}

2. Use Zod for Schema Validation

Zod provides runtime type safety and clear error messages. Define schemas for all domain entities.

const SensorSchema = z.object({
  id: z.string().uuid(),
  type: z.enum(['temperature', 'humidity', 'soil_moisture']),
  status: z.enum(['active', 'inactive', 'error']),
  greenhouseId: z.string().uuid(),
});

3. Encapsulate Business Rules

Business rules belong in Models, not scattered across ViewModels or Views.

class ThresholdAlertModel {
  shouldTriggerAlert(reading: SensorReading, threshold: Threshold): boolean {
    // Business rule: alert if reading exceeds threshold for 3 consecutive readings
    return this.getRecentReadings(reading.sensorId, 3)
      .every(r => r.value > threshold.value);
  }
}

4. Provide Clear APIs

Model methods should have clear names and purposes. Avoid generic methods like update() or process().

// Good: Clear intent
model.markSensorAsInactive(sensorId);
model.calibrateSensor(sensorId, calibrationData);
 
// Bad: Unclear intent
model.update(sensorId, { status: 'inactive' }); // What does this do?

ViewModel Layer Best Practices

1. Expose Observables, Not Subjects

ViewModels should expose read-only observables to Views. Keep BehaviorSubjects private to prevent Views from bypassing ViewModel logic.

// Good: Encapsulated state
class CartViewModel {
  private _items$ = new BehaviorSubject<CartItem[]>([]);
  public readonly items$ = this._items$.asObservable(); // Read-only
  
  addItem(item: CartItem) {
    // Controlled mutation through method
    this._items$.next([...this._items$.value, item]);
  }
}
 
// Bad: Exposed mutable state
class CartViewModel {
  public items$ = new BehaviorSubject<CartItem[]>([]); // Views can mutate directly!
}

2. Use Derived Observables

Compute derived state using RxJS operators rather than storing it separately.

// Good: Derived from source
public readonly total$ = combineLatest([
  this.subtotal$,
  this.tax$,
  this.shipping$
]).pipe(
  map(([subtotal, tax, shipping]) => subtotal + tax + shipping)
);
 
// Bad: Manually synchronized
private _total$ = new BehaviorSubject<number>(0);
updateTotal() {
  const subtotal = this._subtotal$.value;
  const tax = this._tax$.value;
  const shipping = this._shipping$.value;
  this._total$.next(subtotal + tax + shipping); // Easy to forget
}

3. Handle Errors Gracefully

Expose error observables and handle errors at the ViewModel level, not in Views.

class SensorViewModel {
  private _error$ = new BehaviorSubject<string | null>(null);
  public readonly error$ = this._error$.asObservable();
  
  async fetchSensors() {
    try {
      await this.model.fetch();
      this._error$.next(null);
    } catch (error) {
      this._error$.next(error instanceof Error ? error.message : 'Unknown error');
    }
  }
}

4. Implement Proper Cleanup

Always implement dispose() methods to complete observables and prevent memory leaks.

class SensorViewModel {
  private _destroy$ = new Subject<void>();
  
  constructor(model: SensorModel) {
    this.model.data$
      .pipe(takeUntil(this._destroy$))
      .subscribe(/* ... */);
  }
  
  dispose() {
    this._destroy$.next();
    this._destroy$.complete();
  }
}

5. Keep ViewModels Focused

Each ViewModel should manage a single domain concept. Don't create "god ViewModels" that do everything.

// Good: Focused ViewModels
class ProductCatalogViewModel { /* manages product browsing */ }
class CartViewModel { /* manages shopping cart */ }
class CheckoutViewModel { /* manages checkout flow */ }
 
// Bad: God ViewModel
class ECommerceViewModel {
  // Manages products, cart, checkout, orders, user profile...
  // Too much responsibility!
}

View Layer Best Practices

1. Keep Views Thin

Views should subscribe to observables, render UI, and invoke ViewModel methods. No business logic.

// Good: Thin View
function SensorList() {
  const sensors = useObservable(sensorViewModel.data$, []);
  
  return (
    <ul>
      {sensors.map(sensor => (
        <li key={sensor.id}>{sensor.type}</li>
      ))}
    </ul>
  );
}
 
// Bad: Business logic in View
function SensorList() {
  const sensors = useObservable(sensorViewModel.data$, []);
  
  // Business logic doesn't belong here!
  const activeSensors = sensors.filter(s => s.status === 'active');
  const avgTemperature = activeSensors
    .filter(s => s.type === 'temperature')
    .reduce((sum, s) => sum + s.value, 0) / activeSensors.length;
  
  return <div>{avgTemperature}</div>;
}

2. Use Framework-Specific Adapters

Create thin adapter hooks/composables to bridge ViewModels to framework-specific patterns.

// React adapter
function useObservable<T>(observable: Observable<T>, initialValue: T): T {
  const [value, setValue] = useState<T>(initialValue);
  
  useEffect(() => {
    const subscription = observable.subscribe(setValue);
    return () => subscription.unsubscribe();
  }, [observable]);
  
  return value;
}
 
// Vue adapter
function useObservable<T>(observable: Observable<T>, initialValue: T) {
  const value = ref<T>(initialValue);
  
  onMounted(() => {
    const subscription = observable.subscribe(v => value.value = v);
    onUnmounted(() => subscription.unsubscribe());
  });
  
  return value;
}

3. Handle Loading and Error States

Always handle loading and error states in Views. Don't assume data is always available.

function SensorList() {
  const sensors = useObservable(sensorViewModel.data$, []);
  const isLoading = useObservable(sensorViewModel.isLoading$, false);
  const error = useObservable(sensorViewModel.error$, null);
  
  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage message={error} />;
  if (!sensors || sensors.length === 0) return <EmptyState />;
  
  return <SensorGrid sensors={sensors} />;
}

4. Avoid Direct DOM Manipulation

Let the framework handle DOM updates. Don't mix declarative rendering with imperative DOM manipulation.

// Good: Declarative
function Modal({ isOpen, onClose }) {
  if (!isOpen) return null;
  return <div className="modal">...</div>;
}
 
// Bad: Imperative DOM manipulation
function Modal({ isOpen, onClose }) {
  useEffect(() => {
    if (isOpen) {
      document.getElementById('modal').style.display = 'block'; // Don't do this!
    }
  }, [isOpen]);
  
  return <div id="modal">...</div>;
}

5. Use Semantic HTML

Write accessible, semantic HTML. MVVM doesn't excuse poor HTML practices.

// Good: Semantic HTML
<nav aria-label="Main navigation">
  <ul>
    <li><a href="/home">Home</a></li>
    <li><a href="/products">Products</a></li>
  </ul>
</nav>
 
// Bad: Div soup
<div className="nav">
  <div className="nav-item" onClick={() => navigate('/home')}>Home</div>
  <div className="nav-item" onClick={() => navigate('/products')}>Products</div>
</div>

Testing Best Practices

1. Test ViewModels in Isolation

ViewModels should be testable without rendering any UI.

describe('CartViewModel', () => {
  it('calculates total with tax', async () => {
    const vm = new CartViewModel();
    vm.addItem({ id: '1', name: 'Product', price: 100, imageUrl: '' });
    
    const total = await firstValueFrom(vm.total$);
    expect(total).toBe(108); // 100 + 8% tax
    
    vm.dispose();
  });
  
  it('removes items correctly', async () => {
    const vm = new CartViewModel();
    vm.addItem({ id: '1', name: 'Product', price: 100, imageUrl: '' });
    vm.removeItem('1');
    
    const items = await firstValueFrom(vm.items$);
    expect(items).toHaveLength(0);
    
    vm.dispose();
  });
});

2. Mock Dependencies, Not ViewModels

When testing Views, mock the ViewModel's dependencies (Models, services), not the ViewModel itself.

// Good: Mock Model, test real ViewModel
const mockModel = {
  data$: of([{ id: '1', name: 'Sensor 1' }]),
  isLoading$: of(false),
  error$: of(null),
};
 
const viewModel = new SensorViewModel(mockModel);
 
// Bad: Mock entire ViewModel
const mockViewModel = {
  data$: of([...]),
  fetchSensors: jest.fn(),
  // You're not testing anything real!
};

3. Test Error Scenarios

Don't just test happy paths. Test error handling, edge cases, and failure modes.

describe('SensorViewModel error handling', () => {
  it('handles fetch errors gracefully', async () => {
    const mockModel = {
      fetch: jest.fn().mockRejectedValue(new Error('Network error')),
      error$: new BehaviorSubject(null),
    };
    
    const vm = new SensorViewModel(mockModel);
    await vm.fetchSensors();
    
    const error = await firstValueFrom(vm.error$);
    expect(error).toBe('Network error');
  });
});

4. Use Property-Based Testing for Complex Logic

For complex business logic, use property-based testing to verify invariants hold across many inputs.

import fc from 'fast-check';
 
describe('CartViewModel properties', () => {
  it('total is always >= subtotal', () => {
    fc.assert(
      fc.property(
        fc.array(fc.record({
          id: fc.string(),
          name: fc.string(),
          price: fc.nat(),
          imageUrl: fc.string(),
        })),
        (items) => {
          const vm = new CartViewModel();
          items.forEach(item => vm.addItem(item));
          
          const subtotal = vm.subtotal$.value;
          const total = vm.total$.value;
          
          expect(total).toBeGreaterThanOrEqual(subtotal);
          vm.dispose();
        }
      )
    );
  });
});

Performance Best Practices

1. Memoize Expensive Computations

Use RxJS operators like distinctUntilChanged() and shareReplay() to avoid redundant computations.

// Avoid recomputing if data hasn't changed
public readonly expensiveComputation$ = this.data$.pipe(
  distinctUntilChanged(),
  map(data => this.performExpensiveOperation(data)),
  shareReplay(1) // Cache result for multiple subscribers
);

2. Debounce User Input

Debounce search queries and other frequent user inputs to reduce unnecessary API calls.

class SearchViewModel {
  private _searchQuery$ = new BehaviorSubject<string>('');
  
  public readonly searchResults$ = this._searchQuery$.pipe(
    debounceTime(300), // Wait 300ms after user stops typing
    distinctUntilChanged(),
    switchMap(query => this.searchAPI(query))
  );
  
  setSearchQuery(query: string) {
    this._searchQuery$.next(query);
  }
}

3. Unsubscribe Properly

Always unsubscribe from observables to prevent memory leaks. Use takeUntil() pattern consistently.

class SensorViewModel {
  private _destroy$ = new Subject<void>();
  
  constructor(model: SensorModel) {
    // All subscriptions use takeUntil
    model.data$
      .pipe(takeUntil(this._destroy$))
      .subscribe(/* ... */);
  }
  
  dispose() {
    this._destroy$.next();
    this._destroy$.complete();
  }
}

4. Lazy Load ViewModels

Don't instantiate all ViewModels upfront. Create them when needed.

// Good: Lazy instantiation
function ProductDetail({ productId }) {
  const [viewModel] = useState(() => new ProductDetailViewModel(productId));
  // ViewModel created only when component mounts
}
 
// Bad: Eager instantiation
// All ViewModels created at app startup, even if never used
export const productViewModel1 = new ProductDetailViewModel('1');
export const productViewModel2 = new ProductDetailViewModel('2');
// ...

23.6 Common Pitfalls and How to Avoid Them

Let's address the most common mistakes developers make when implementing MVVM.

Pitfall 1: Putting Business Logic in Views

The Mistake:

function ProductList() {
  const products = useObservable(productViewModel.data$, []);
  
  // Business logic in View!
  const discountedProducts = products.map(p => ({
    ...p,
    price: p.price * 0.9, // 10% discount
    isOnSale: p.price > 100,
  }));
  
  return <ProductGrid products={discountedProducts} />;
}

Why It's Wrong: Business logic in Views can't be tested independently, can't be reused across frameworks, and violates separation of concerns.

The Fix: Move business logic to ViewModel.

class ProductViewModel {
  public readonly discountedProducts$ = this.products$.pipe(
    map(products => products.map(p => ({
      ...p,
      price: p.price * 0.9,
      isOnSale: p.price > 100,
    })))
  );
}
 
function ProductList() {
  const products = useObservable(productViewModel.discountedProducts$, []);
  return <ProductGrid products={products} />;
}

Pitfall 2: Exposing Mutable State

The Mistake:

class CartViewModel {
  public items = []; // Mutable array exposed directly!
  
  addItem(item: CartItem) {
    this.items.push(item); // Views can also mutate this!
  }
}

Why It's Wrong: Views can bypass ViewModel logic by mutating state directly, breaking encapsulation and validation.

The Fix: Expose observables, keep state private.

class CartViewModel {
  private _items$ = new BehaviorSubject<CartItem[]>([]);
  public readonly items$ = this._items$.asObservable(); // Read-only
  
  addItem(item: CartItem) {
    this._items$.next([...this._items$.value, item]);
  }
}

Pitfall 3: Forgetting to Unsubscribe

The Mistake:

function SensorList() {
  const [sensors, setSensors] = useState([]);
  
  useEffect(() => {
    sensorViewModel.data$.subscribe(setSensors);
    // No cleanup! Memory leak!
  }, []);
  
  return <ul>{/* ... */}</ul>;
}

Why It's Wrong: Subscriptions that aren't cleaned up cause memory leaks. The subscription persists even after the component unmounts.

The Fix: Always unsubscribe in cleanup.

function SensorList() {
  const [sensors, setSensors] = useState([]);
  
  useEffect(() => {
    const subscription = sensorViewModel.data$.subscribe(setSensors);
    return () => subscription.unsubscribe(); // Cleanup!
  }, []);
  
  return <ul>{/* ... */}</ul>;
}
 
// Or use a custom hook that handles cleanup
function SensorList() {
  const sensors = useObservable(sensorViewModel.data$, []);
  return <ul>{/* ... */}</ul>;
}

Pitfall 4: Creating Framework-Dependent ViewModels

The Mistake:

class ProductViewModel {
  private [products, setProducts] = useState([]); // React-specific!
  
  async fetchProducts() {
    const data = await api.getProducts();
    setProducts(data); // Can't use in Vue or Angular
  }
}

Why It's Wrong: Framework-specific APIs in ViewModels destroy framework independence. You can't reuse this ViewModel in other frameworks.

The Fix: Use framework-agnostic patterns (observables, plain TypeScript).

class ProductViewModel {
  private _products$ = new BehaviorSubject<Product[]>([]);
  public readonly products$ = this._products$.asObservable();
  
  async fetchProducts() {
    const data = await api.getProducts();
    this._products$.next(data); // Works everywhere
  }
}

Pitfall 5: Over-Engineering Simple Features

The Mistake:

// For a simple static page
class AboutPageViewModel {
  private _title$ = new BehaviorSubject<string>('About Us');
  public readonly title$ = this._title$.asObservable();
  
  private _content$ = new BehaviorSubject<string>('We are a company...');
  public readonly content$ = this._content$.asObservable();
  
  // Overkill for static content!
}

Why It's Wrong: Not everything needs a ViewModel. Simple, static content doesn't benefit from MVVM's abstraction.

The Fix: Use framework-native patterns for simple features.

// Just use a component
function AboutPage() {
  return (
    <div>
      <h1>About Us</h1>
      <p>We are a company...</p>
    </div>
  );
}

Pitfall 6: Tight Coupling Between ViewModels

The Mistake:

class CheckoutViewModel {
  constructor(private cartViewModel: CartViewModel) {}
  
  placeOrder() {
    // Directly accessing another ViewModel's internals
    const items = this.cartViewModel._items$.value; // Accessing private state!
  }
}

Why It's Wrong: Tight coupling makes ViewModels hard to test and change independently.

The Fix: Use public observables and clear interfaces.

class CheckoutViewModel {
  constructor(private cartViewModel: CartViewModel) {}
  
  public readonly orderSummary$ = combineLatest([
    this.cartViewModel.items$, // Public observable
    this.cartViewModel.total$,
  ]).pipe(
    map(([items, total]) => ({ items, total }))
  );
}

Pitfall 7: Ignoring Error Handling

The Mistake:

class SensorViewModel {
  async fetchSensors() {
    const data = await api.getSensors(); // What if this fails?
    this._data$.next(data);
  }
}

Why It's Wrong: Unhandled errors crash the application or leave it in an inconsistent state.

The Fix: Always handle errors explicitly.

class SensorViewModel {
  private _error$ = new BehaviorSubject<string | null>(null);
  public readonly error$ = this._error$.asObservable();
  
  async fetchSensors() {
    try {
      this._error$.next(null);
      const data = await api.getSensors();
      this._data$.next(data);
    } catch (error) {
      this._error$.next(error instanceof Error ? error.message : 'Unknown error');
    }
  }
}

23.7 Migration Strategies

If you have an existing application and want to adopt MVVM, here's how to do it incrementally.

Strategy 1: Extract ViewModels from Complex Components

Start with your most complex components—those with lots of state, business logic, and API calls.

Before:

function CheckoutPage() {
  const [step, setStep] = useState('shipping');
  const [shippingAddress, setShippingAddress] = useState(null);
  const [paymentMethod, setPaymentMethod] = useState(null);
  const [isProcessing, setIsProcessing] = useState(false);
  const [error, setError] = useState(null);
  
  const handleSubmit = async () => {
    setIsProcessing(true);
    try {
      await api.placeOrder({ shippingAddress, paymentMethod });
      navigate('/confirmation');
    } catch (e) {
      setError(e.message);
    } finally {
      setIsProcessing(false);
    }
  };
  
  // 200 more lines of logic...
}

After:

// Extract to ViewModel
class CheckoutViewModel {
  private _step$ = new BehaviorSubject('shipping');
  private _shippingAddress$ = new BehaviorSubject(null);
  private _paymentMethod$ = new BehaviorSubject(null);
  private _isProcessing$ = new BehaviorSubject(false);
  private _error$ = new BehaviorSubject(null);
  
  public readonly step$ = this._step$.asObservable();
  public readonly shippingAddress$ = this._shippingAddress$.asObservable();
  public readonly paymentMethod$ = this._paymentMethod$.asObservable();
  public readonly isProcessing$ = this._isProcessing$.asObservable();
  public readonly error$ = this._error$.asObservable();
  
  async placeOrder() {
    this._isProcessing$.next(true);
    this._error$.next(null);
    try {
      await api.placeOrder({
        shippingAddress: this._shippingAddress$.value,
        paymentMethod: this._paymentMethod$.value,
      });
    } catch (e) {
      this._error$.next(e.message);
    } finally {
      this._isProcessing$.next(false);
    }
  }
}
 
// Simplified component
function CheckoutPage() {
  const [viewModel] = useState(() => new CheckoutViewModel());
  const step = useObservable(viewModel.step$, 'shipping');
  const isProcessing = useObservable(viewModel.isProcessing$, false);
  const error = useObservable(viewModel.error$, null);
  
  return <CheckoutFlow viewModel={viewModel} />;
}

Strategy 2: Create Shared ViewModels for Cross-Component State

If multiple components share state (cart, user session, global settings), extract that state into a shared ViewModel.

Before:

// State duplicated across components
function Header() {
  const [cartCount, setCartCount] = useState(0);
  // Fetch cart count...
}
 
function CartWidget() {
  const [cartCount, setCartCount] = useState(0);
  // Fetch cart count again...
}
 
function ProductCard() {
  const addToCart = () => {
    // Update cart, but how do Header and CartWidget know?
  };
}

After:

// Shared ViewModel
export const cartViewModel = new CartViewModel();
 
function Header() {
  const cartCount = useObservable(cartViewModel.itemCount$, 0);
  return <div>Cart ({cartCount})</div>;
}
 
function CartWidget() {
  const cartCount = useObservable(cartViewModel.itemCount$, 0);
  return <div>{cartCount} items</div>;
}
 
function ProductCard({ product }) {
  const addToCart = () => {
    cartViewModel.addItem(product); // All components update automatically
  };
  return <button onClick={addToCart}>Add to Cart</button>;
}

Strategy 3: Introduce Models for Business Logic

If you have business logic scattered across components, extract it into Models.

Before:

function ProductList() {
  const [products, setProducts] = useState([]);
  
  const applyDiscount = (product) => {
    // Business logic in component
    if (product.category === 'electronics' && product.price > 100) {
      return { ...product, price: product.price * 0.9 };
    }
    return product;
  };
  
  return products.map(p => <ProductCard product={applyDiscount(p)} />);
}

After:

// Business logic in Model
class ProductModel {
  applyDiscount(product: Product): Product {
    if (product.category === 'electronics' && product.price > 100) {
      return { ...product, price: product.price * 0.9 };
    }
    return product;
  }
}
 
// ViewModel uses Model
class ProductViewModel {
  constructor(private model: ProductModel) {}
  
  public readonly discountedProducts$ = this.products$.pipe(
    map(products => products.map(p => this.model.applyDiscount(p)))
  );
}
 
// Component is simple
function ProductList() {
  const products = useObservable(productViewModel.discountedProducts$, []);
  return products.map(p => <ProductCard product={p} />);
}

Strategy 4: Gradual Adoption

You don't have to migrate everything at once. Use MVVM for new features while leaving existing code unchanged.

Hybrid Approach:

// New feature: MVVM
function NewCheckoutFlow() {
  const viewModel = useViewModel(() => new CheckoutViewModel());
  return <CheckoutView viewModel={viewModel} />;
}
 
// Existing feature: Keep as-is
function LegacyProductList() {
  const [products, setProducts] = useState([]);
  // Existing code unchanged
}

Over time, migrate legacy features to MVVM as you touch them. This minimizes risk and allows the team to learn gradually.

23.8 The Future of MVVM in Frontend Development

MVVM isn't new—it's been solving architectural problems for nearly two decades. But its relevance to frontend development is growing, not shrinking. Here's why.

Trend 1: Framework Fatigue and Portability

The JavaScript ecosystem churns through frameworks rapidly. React, Vue, Angular, Svelte, Solid, Qwik—each promises to be "the one." But betting your entire codebase on a single framework is risky.

MVVM provides insurance. When the next framework emerges, you rewrite the View layer, not your business logic. Your ViewModels, Models, and domain logic remain unchanged.

As developers tire of framework churn, architectural patterns that provide portability will become more valuable.

Trend 2: Cross-Platform Development

Modern applications span web, mobile, desktop, and embedded devices. Sharing business logic across platforms is no longer optional—it's a competitive advantage.

MVVM's framework independence makes cross-platform development practical. Write ViewModels once, use them in React (web), React Native (mobile), Electron (desktop), and even server-side rendering.

As cross-platform development becomes the norm, MVVM's value proposition strengthens.

Trend 3: AI-Assisted Development

AI code generation tools (GitHub Copilot, ChatGPT, etc.) work best with clear, well-structured code. MVVM's explicit separation of concerns makes it easier for AI to:

  • Generate ViewModels from domain descriptions
  • Create Views from ViewModel interfaces
  • Write tests for isolated layers

As AI-assisted development matures, architectural patterns that provide clear boundaries will be easier to work with.

Trend 4: Reactive Programming Mainstream Adoption

Reactive programming (observables, signals, streams) is becoming mainstream. React's upcoming "React Forget" compiler, Vue's reactivity system, Angular's signals, Solid's fine-grained reactivity—all embrace reactive patterns.

MVVM has always been reactive at its core. As frameworks converge on reactive patterns, MVVM becomes more natural to implement, not less.

Trend 5: TypeScript Ubiquity

TypeScript adoption is near-universal in modern frontend development. MVVM benefits enormously from TypeScript's type safety:

  • ViewModels have strongly-typed interfaces
  • Models enforce domain constraints at compile time
  • Views get autocomplete and type checking for ViewModel APIs

As TypeScript becomes the default, MVVM's type-safe boundaries become more valuable.

Trend 6: Testing Culture Maturation

The frontend community is finally taking testing seriously. Property-based testing, mutation testing, and comprehensive test coverage are no longer niche practices.

MVVM's testability is a perfect fit for this culture shift. As teams demand higher test coverage and faster test execution, MVVM's ability to test business logic without rendering components becomes essential.

The Path Forward

MVVM isn't a silver bullet, but it's a proven pattern that solves real problems. As frontend development matures, the industry is rediscovering patterns that desktop and mobile developers have used for years.

The future of MVVM in frontend development is bright because the problems it solves—framework lock-in, untestable code, tangled concerns—aren't going away. If anything, they're getting worse as applications grow more complex.

MVVM provides a path forward: clear boundaries, testable code, framework independence, and maintainable architecture. These principles are timeless.

23.9 Final Thoughts

We started this book with a crisis: frontend codebases collapsing under their own weight. We end with a solution: MVVM architecture that scales from components to applications, from single frameworks to cross-platform systems.

You've learned the patterns. You've seen the code. You've studied complete applications. Now it's your turn to apply these principles to your own projects.

Remember These Core Principles

1. Separation of Concerns: Models handle business logic, ViewModels manage presentation state, Views render UI. Keep these boundaries clear.

2. Framework Independence: Business logic should work in any framework. Use framework-agnostic patterns (observables, plain TypeScript) in ViewModels and Models.

3. Reactive State: Expose observables, not mutable state. Let state changes propagate automatically through reactive streams.

4. Testability: Test ViewModels without rendering components. Test Models without ViewModels. Test Views with mocked ViewModels.

5. Single Source of Truth: Each piece of state has one authoritative source. Derive everything else using observables.

6. Pragmatism Over Dogma: Use MVVM where it provides value. Don't force it everywhere. Simple features can use simpler patterns.

Start Small, Scale Gradually

You don't need to rewrite your entire application tomorrow. Start with:

  1. One complex component: Extract its logic into a ViewModel. See how it feels.
  2. One shared state: Create a singleton ViewModel for cart, auth, or global settings.
  3. One new feature: Build it with MVVM from the start.

As you gain confidence, expand MVVM to more of your codebase. The patterns will become second nature.

Learn from Real Code

The best way to learn MVVM is to work with real implementations. The Web Loom monorepo contains:

  • Complete ViewModels: packages/view-models/src/
  • Framework implementations: apps/mvvm-react/, apps/mvvm-vue/, apps/mvvm-angular/, apps/mvvm-lit/, apps/mvvm-vanilla/
  • Supporting libraries: packages/mvvm-core/, packages/store-core/, packages/event-bus-core/, packages/query-core/
  • Real applications: GreenWatch monitoring system, e-commerce workflows, plugin architecture

Clone the repository. Run the applications. Modify the ViewModels. See how changes propagate across frameworks. There's no substitute for hands-on experience.

Join the Community

MVVM in frontend development is still evolving. Share your experiences:

  • Write about your MVVM implementations
  • Contribute to open-source MVVM libraries
  • Share patterns you've discovered
  • Help others learn these principles

The frontend community benefits when we share knowledge and learn from each other's experiences.

The Journey Continues

This book ends, but your journey with MVVM is just beginning. You'll encounter challenges we haven't covered. You'll discover patterns we haven't mentioned. You'll adapt MVVM to domains we haven't explored.

That's good. MVVM is a framework, not a prescription. The principles are timeless, but the implementations evolve with technology and requirements.

As you build applications with MVVM, remember why we started this journey: to create maintainable, testable, framework-independent code that we're proud to work on for years to come.

The frontend architecture crisis is real, but the solution is within reach. You now have the tools, patterns, and principles to build better applications.

Go build something great.


Key Takeaways

Core Principles:

  • Separation of concerns: Models, ViewModels, Views
  • Framework independence: Business logic works everywhere
  • Reactive state: Observables propagate changes automatically
  • Testability: Test each layer independently
  • Single source of truth: Derive state, don't duplicate it

When to Use MVVM:

  • Complex business logic
  • Framework portability needs
  • Testing is a priority
  • Multiple teams collaborate
  • Non-trivial state management

When Not to Use MVVM:

  • Truly simple applications
  • Early-stage prototypes
  • Team lacks experience
  • Framework-specific features are critical
  • Performance is paramount

Best Practices:

  • Keep Models pure (business logic only)
  • Expose observables, not subjects (encapsulation)
  • Use derived observables (avoid duplicate state)
  • Handle errors gracefully (expose error observables)
  • Implement proper cleanup (dispose methods)
  • Keep Views thin (no business logic)
  • Test ViewModels in isolation (no DOM)
  • Memoize expensive computations (distinctUntilChanged, shareReplay)
  • Debounce user input (reduce API calls)
  • Unsubscribe properly (prevent memory leaks)

Common Pitfalls:

  • Business logic in Views
  • Exposing mutable state
  • Forgetting to unsubscribe
  • Framework-dependent ViewModels
  • Over-engineering simple features
  • Tight coupling between ViewModels
  • Ignoring error handling

Migration Strategies:

  • Extract ViewModels from complex components
  • Create shared ViewModels for cross-component state
  • Introduce Models for business logic
  • Adopt gradually (new features first)

The Future:

  • Framework fatigue drives portability needs
  • Cross-platform development becomes standard
  • AI-assisted development benefits from clear structure
  • Reactive programming goes mainstream
  • TypeScript ubiquity strengthens type-safe boundaries
  • Testing culture matures

Thank you for reading. You've completed the journey from frontend architecture crisis to production-ready MVVM applications. The patterns you've learned are timeless. The code you'll write will be maintainable. The applications you'll build will scale.

Now go apply these principles. Build something you're proud of. And when you do, remember: architecture matters. Separation of concerns matters. Testability matters. Framework independence matters.

MVVM gives you all of these. Use it wisely.

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