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
sensorViewModelimport - Same
data$andisLoading$observables - Same
fetchCommand.execute()method - Zero business logic duplication
What's different:
- Only the UI rendering (JSX vs Vue template)
- Framework-specific subscription patterns (
useObservablehook)
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:
- User action: User clicks "Refresh Sensors" button in React
- View layer: React component calls
sensorViewModel.fetchCommand.execute() - ViewModel layer: ViewModel coordinates the operation:
- Sets
isLoading$totrue - Calls Model's fetch method
- Validates response with Zod
- Updates
data$with validated data - Sets
isLoading$tofalse
- Sets
- 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
- MVVM separates concerns: Model (data), View (UI), ViewModel (presentation logic)
- Framework independence: Write business logic once, use it in any framework
- Testability: Test business logic without rendering UI (10-100x faster)
- Reactive state: Observables provide automatic UI updates
- Centralized validation: Zod schemas in Models ensure data integrity
- 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 ViewModelapps/mvvm-react/src/components/SensorList.tsx- React implementationapps/mvvm-vue/src/components/SensorList.vue- Vue implementationpackages/mvvm-core/src/models/BaseModel.ts- Reactive Model foundationapps/mvvm-react/src/hooks/useObservable.ts- React observable hookapps/mvvm-vue/src/hooks/useObservable.ts- Vue observable composable