Web Loom logo
Chapter 02Foundations

Why MVVM Matters for Modern Frontend

Why MVVM Matters for Modern Frontend

In the previous chapter, we explored the architectural crisis facing modern frontend development: tightly coupled code, framework lock-in, testing nightmares, and maintenance headaches. Now let's see how MVVM (Model-View-ViewModel) provides concrete solutions to these problems.

The Promise of MVVM

MVVM isn't just another architectural pattern—it's a systematic approach to separating concerns in frontend applications. By dividing your application into three distinct layers (Model, View, and ViewModel), MVVM enables:

  • Framework independence: Write business logic once, use it everywhere
  • Testability: Test business logic without rendering UI
  • Maintainability: Change UI frameworks without rewriting logic
  • Team collaboration: Frontend and business logic teams work independently

Let's see these benefits in action using real code from the GreenWatch greenhouse monitoring system.

Problem 1: Framework Lock-In

The Traditional Approach

In traditional frontend applications, business logic is tightly coupled to the UI framework. Consider a typical React component that manages sensor data:

// Traditional approach: Business logic mixed with UI
function SensorDashboard() {
  const [sensors, setSensors] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    const fetchSensors = async () => {
      setIsLoading(true);
      try {
        const response = await fetch('/api/sensors');
        const data = await response.json();
        setSensors(data);
      } catch (err) {
        setError(err);
      } finally {
        setIsLoading(false);
      }
    };
    fetchSensors();
  }, []);
 
  return (
    <div>
      {isLoading && <p>Loading...</p>}
      {error && <p>Error: {error.message}</p>}
      {sensors.map(sensor => (
        <div key={sensor.id}>{sensor.type}</div>
      ))}
    </div>
  );
}

Problems with this approach:

  • Business logic (data fetching, state management, error handling) is embedded in React
  • Cannot reuse this logic in Vue, Angular, or other frameworks
  • Cannot test the logic without rendering React components
  • Switching frameworks means rewriting everything

The MVVM Solution

MVVM solves this by extracting business logic into a framework-agnostic ViewModel. Here's the actual SensorViewModel from GreenWatch:

// 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);
export type { SensorListData };

This ViewModel is completely framework-agnostic. It doesn't import React, Vue, or any UI framework. It exposes reactive observables (data$, isLoading$, error$) that any framework can consume.

Using the Same ViewModel in React

Here's how the React implementation consumes the ViewModel:

// apps/mvvm-react/src/components/SensorList.tsx
import { useEffect } from 'react';
import { sensorViewModel } from '@repo/view-models/SensorViewModel';
import { useObservable } from '../hooks/useObservable';
 
export function SensorList() {
  const sensors = useObservable(sensorViewModel.data$, []);
 
  useEffect(() => {
    const fetchData = async () => {
      await sensorViewModel.fetchCommand.execute();
    };
    fetchData();
  }, []);
 
  return (
    <div className="card">
      <h1 className="card-title">Sensors</h1>
      {sensors && sensors.length > 0 ? (
        <ul className="card-content list">
          {sensors.map((sensor) => (
            <li key={sensor.id} className="list-item">
              {sensor.greenhouse.name} {sensor.type} (Status: {sensor.status})
            </li>
          ))}
        </ul>
      ) : (
        <p>No sensors found or still loading...</p>
      )}
    </div>
  );
}

The React component is now a "dumb view"—it only handles rendering. All business logic lives in the ViewModel.

Using the Same ViewModel in Vue

Here's the exact same ViewModel used in Vue:

<!-- apps/mvvm-vue/src/components/SensorList.vue -->
<template>
  <div class="card">
    <h4 class="card-title">Sensors List</h4>
    <div v-if="isLoading">
      <p class="content">Loading sensors...</p>
    </div>
    <div v-else-if="filteredSensors && filteredSensors.length > 0">
      <ul class="card-content list">
        <li v-for="sensor in filteredSensors" :key="sensor.id" class="list-item">
          Sensor Type: {{ sensor.type }}
          <span v-if="sensor.greenhouse"> | Greenhouse: {{ sensor.greenhouse.name }}</span>
          (Status: {{ sensor.status || 'N/A' }})
        </li>
      </ul>
    </div>
    <div v-else>
      <p>No sensors found.</p>
    </div>
  </div>
</template>
 
<script setup lang="ts">
import { onMounted } from 'vue';
import { sensorViewModel } from '@repo/view-models/SensorViewModel';
import { useObservable } from '../hooks/useObservable';
 
const isLoading = useObservable(sensorViewModel.isLoading$, true);
const allSensors = useObservable(sensorViewModel.data$, []);
 
onMounted(() => {
  sensorViewModel.fetchCommand.execute();
});
</script>

Notice what's identical:

  • Same sensorViewModel import
  • Same data$ and isLoading$ observables
  • Same fetchCommand.execute() method
  • Zero business logic duplication

What's different:

  • Only the UI rendering (JSX vs Vue template)
  • Framework-specific subscription patterns (useObservable hook)

This is the power of MVVM: write business logic once, use it in any framework.

Problem 2: Untestable Business Logic

The Traditional Testing Challenge

Testing the traditional React component requires:

  • Rendering the component with a testing library
  • Mocking fetch API calls
  • Simulating loading states
  • Asserting on DOM elements
// Traditional testing: Complex and brittle
test('loads sensors', async () => {
  global.fetch = jest.fn(() =>
    Promise.resolve({
      json: () => Promise.resolve([{ id: 1, type: 'temperature' }])
    })
  );
  
  render(<SensorDashboard />);
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  await waitFor(() => {
    expect(screen.getByText('temperature')).toBeInTheDocument();
  });
});

This test is slow, brittle, and tightly coupled to React.

The MVVM Testing Advantage

With MVVM, you test the ViewModel directly—no UI rendering required:

// Testing the ViewModel: Fast and framework-agnostic
test('SensorViewModel loads sensor data', async () => {
  const viewModel = new SensorViewModel(mockSensorModel);
  
  // Subscribe to observables
  const dataValues: any[] = [];
  viewModel.data$.subscribe(data => dataValues.push(data));
  
  // Execute command
  await viewModel.fetchCommand.execute();
  
  // Assert on business logic
  expect(dataValues).toHaveLength(2); // null, then data
  expect(dataValues[1]).toHaveLength(3); // 3 sensors
  expect(dataValues[1][0].type).toBe('temperature');
});

Benefits:

  • No DOM rendering (10-100x faster)
  • No framework dependencies
  • Tests pure business logic
  • Same tests work regardless of UI framework

Problem 3: Reactive State Management

The Challenge of State Synchronization

Modern applications need reactive state—when data changes, the UI should update automatically. Traditional approaches require manual state management:

// Manual state updates: Error-prone
function updateSensor(id, newData) {
  setSensors(prev => prev.map(s => 
    s.id === id ? { ...s, ...newData } : s
  ));
}

MVVM's Reactive Foundation

MVVM uses reactive observables at its core. Here's the BaseModel that powers all GreenWatch models:

// packages/mvvm-core/src/models/BaseModel.ts
export class BaseModel<TData, TSchema extends ZodSchema<TData>> {
  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 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);
  }
}

Key features:

  • BehaviorSubject: Holds current state and emits to new subscribers
  • Observable: Read-only stream that Views subscribe to
  • Automatic updates: When setData() is called, all subscribers update automatically

Framework-Specific Subscription Patterns

Each framework subscribes to these observables using its own patterns:

React with hooks:

// apps/mvvm-react/src/hooks/useObservable.ts
export function useObservable<T>(observable: Observable<T>, initialValue: T) {
  const [value, setValue] = useState<T>(initialValue);
 
  useEffect(() => {
    const subscription = observable.subscribe(setValue);
    return () => subscription.unsubscribe();
  }, [observable]);
 
  return value;
}

Vue with Composition API:

// apps/mvvm-vue/src/hooks/useObservable.ts
export function useObservable<T>(observable: Observable<T>, initialValue: any) {
  const value = ref<T | undefined>(initialValue);
  const subscription = observable.subscribe({
    next: (val) => {
      value.value = val;
    },
  });
 
  onUnmounted(() => {
    subscription.unsubscribe();
  });
 
  return value;
}

Both hooks do the same thing—subscribe to an observable and update local state—but use framework-specific APIs. The ViewModel remains unchanged.

Problem 4: Validation and Error Handling

Scattered Validation Logic

Traditional applications scatter validation across components:

// Validation mixed with UI
function SensorForm() {
  const [errors, setErrors] = useState({});
  
  const handleSubmit = (data) => {
    const newErrors = {};
    if (!data.type) newErrors.type = 'Type is required';
    if (data.threshold < 0) newErrors.threshold = 'Must be positive';
    setErrors(newErrors);
  };
}

Centralized Validation with Zod

MVVM centralizes validation in the Model layer using Zod schemas:

// Validation in the Model layer
export const SensorSchema = z.object({
  id: z.string(),
  type: z.enum(['temperature', 'humidity', 'soil_moisture']),
  status: z.enum(['active', 'inactive', 'error']),
  threshold: z.number().positive(),
  greenhouseId: z.string()
});
 
export class BaseModel<TData, TSchema extends ZodSchema<TData>> {
  public validate(data: any): TData {
    if (!this.schema) {
      return data as TData;
    }
    return this.schema.parse(data); // Throws ZodError if invalid
  }
}

Benefits:

  • Single source of truth for validation rules
  • Type-safe validation with TypeScript
  • Automatic error messages
  • Reusable across all frameworks

The Complete Picture

Let's see how all these pieces work together in a real GreenWatch scenario:

  1. User action: User clicks "Refresh Sensors" button in React
  2. View layer: React component calls sensorViewModel.fetchCommand.execute()
  3. ViewModel layer: ViewModel coordinates the operation:
    • Sets isLoading$ to true
    • Calls Model's fetch method
    • Validates response with Zod
    • Updates data$ with validated data
    • Sets isLoading$ to false
  4. Reactive update: All subscribed Views (React, Vue, Angular) automatically re-render with new data

The same flow works identically in Vue, Angular, Lit, or Vanilla JS because the ViewModel is framework-agnostic.

When MVVM Shines

MVVM is particularly valuable when:

  • Building for multiple platforms: Web, mobile, desktop with shared logic
  • Framework migration: Need to switch frameworks without rewriting logic
  • Large teams: Frontend and business logic teams work independently
  • Complex business logic: Logic is too complex to mix with UI
  • High test coverage: Need fast, reliable unit tests for business logic

When MVVM Might Be Overkill

MVVM adds architectural overhead. Consider simpler patterns for:

  • Simple CRUD apps: Minimal business logic, framework-specific is fine
  • Prototypes: Speed matters more than architecture
  • Single-framework projects: No plans to support multiple frameworks
  • Small teams: Overhead of separation isn't worth it

Key Takeaways

  1. MVVM separates concerns: Model (data), View (UI), ViewModel (presentation logic)
  2. Framework independence: Write business logic once, use it in any framework
  3. Testability: Test business logic without rendering UI (10-100x faster)
  4. Reactive state: Observables provide automatic UI updates
  5. Centralized validation: Zod schemas in Models ensure data integrity
  6. Real-world proven: GreenWatch demonstrates MVVM across React, Vue, Angular, Lit, and Vanilla JS

What's Next

Now that you understand why MVVM matters, the next chapter dives deep into the fundamentals: the three layers of MVVM, their responsibilities, and how data flows through the architecture. We'll build a complete mental model of MVVM before implementing it in code.


Code References:

  • packages/view-models/src/SensorViewModel.ts - Framework-agnostic ViewModel
  • apps/mvvm-react/src/components/SensorList.tsx - React implementation
  • apps/mvvm-vue/src/components/SensorList.vue - Vue implementation
  • packages/mvvm-core/src/models/BaseModel.ts - Reactive Model foundation
  • apps/mvvm-react/src/hooks/useObservable.ts - React observable hook
  • apps/mvvm-vue/src/hooks/useObservable.ts - Vue observable composable
Web Loom logo
Copyright © Web Loom. All rights reserved.