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 automaticallyTestability 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:
- One complex component: Extract its logic into a ViewModel. See how it feels.
- One shared state: Create a singleton ViewModel for cart, auth, or global settings.
- 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.