Chapter 1: The Frontend Architecture Crisis
1.1 The Problem We've Created
Let's be honest: we've made a mess of frontend development.
I've spent the last decade watching teams struggle with the same architectural problems, over and over. Codebases that start clean devolve into tangled webs of components where business logic lives everywhere and nowhere. Tests that mock so many layers they're testing mocks, not behavior. Developers who can't switch frameworks without rewriting everything from scratch.
This isn't a tooling problem. We've got incredible frameworks—React, Vue, Angular—each with their own strengths. The problem is how we're using them.
We've let the framework become the architecture. We've conflated "component-based" with "well-structured." We've prioritized shipping features over building maintainable systems. And now we're paying the price.
1.2 How We Got Here
The shift to component-based frameworks was necessary. After years of jQuery spaghetti and template soup, the ability to encapsulate UI into reusable pieces felt revolutionary. React gave us a declarative mental model. Vue made reactivity intuitive. Angular brought enterprise patterns to the frontend.
But somewhere along the way, we forgot the lessons that desktop and mobile developers had already learned. We abandoned proven architectural patterns—patterns like MVVM that had solved these exact problems in WPF, iOS, and Android development. We convinced ourselves that the web was "different" and needed new approaches.
It wasn't different. We just didn't bother learning from those who'd come before us.
Consider this typical React component pattern that appears in many codebases:
function UserDashboard() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchData() {
try {
const userResponse = await fetch(`/api/users/${userId}`);
const userData = await userResponse.json();
setUser(userData);
const postsResponse = await fetch(`/api/users/${userId}/posts`);
const postsData = await postsResponse.json();
setPosts(postsData.filter(p => p.published));
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}
fetchData();
}, [userId]);
const handlePublish = async (postId) => {
try {
await fetch(`/api/posts/${postId}/publish`, { method: 'POST' });
setPosts(posts.map(p =>
p.id === postId ? { ...p, published: true } : p
));
} catch (e) {
setError(e.message);
}
};
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return (
<div>
<UserProfile user={user} />
<PostsList posts={posts} onPublish={handlePublish} />
</div>
);
}This looks familiar, right? It's the kind of code we write every day. But look closer:
- Business logic is embedded in the UI layer. The filtering logic (
p.published), the data transformation, the publishing workflow—it's all trapped inside a component. - State management is scattered. We're managing
user,posts,loading, anderroras independent pieces of state, manually keeping them synchronized. - Testing requires mounting components. Want to test the publishing logic? You'll need to render the entire component, mock fetch, and simulate user interactions.
- Reusability is impossible. If you need this same logic in a mobile app or different framework, you're rewriting it from scratch.
This isn't a React problem. I've seen identical issues in Vue and Angular codebases. The framework isn't the issue—our lack of architecture is.
1.3 The Cost of Framework-First Architecture
When we let the framework dictate our architecture, we pay specific, measurable costs:
1. Testability collapses. Without clear separation between business logic and UI, we're forced to test them together. This means:
- Slower test execution (rendering components is expensive)
- Brittle tests that break when UI changes
- Lower test coverage because integration tests are harder to write
- Difficulty achieving true unit test isolation
I've worked with teams where the test suite took 15 minutes to run because every test mounted components. When tests are slow, developers stop running them. When tests are brittle, developers stop trusting them. The whole testing pyramid inverts.
2. Cognitive load explodes. Developers need to hold the entire system in their heads. There's no clear boundary between "how we present data" and "what the data means." This makes:
- Onboarding new developers painfully slow
- Code reviews exhausting and error-prone
- Feature development increasingly time-consuming as the codebase grows
- Debugging a nightmare when issues span multiple concerns
3. Framework lock-in becomes total. Your business logic—the actual value your application provides—is imprisoned in framework-specific code. Want to:
- Share logic between web and mobile? Rewrite it.
- Migrate to a new framework? Rewrite it.
- Test your domain logic independently? Can't. It's married to the view.
This isn't theoretical. I've seen companies spend months rewriting applications during framework migrations, not because the frameworks are incompatible, but because they never separated their business logic in the first place.
4. Collaboration deteriorates. When everything is a component, there's no clear ownership. Backend developers can't contribute to business logic because it's tangled with UI code. Frontend developers can't focus on user experience because they're also managing state, API calls, and business rules. Everyone steps on everyone else's toes.
1.4 Why MVVM Is the Solution We Need
MVVM isn't a silver bullet. It's not even new—it's been solving these exact problems in other platforms for nearly two decades. Microsoft introduced it for WPF in 2005. iOS developers adopted it. Android teams embraced it. It works.
The pattern is straightforward:
- Model: Your domain logic and data structures. Pure TypeScript classes and interfaces that represent your business entities and rules. No framework code. No UI concerns.
- View: Your framework-specific presentation layer. React components, Vue templates, Angular components—whatever you're using. Its job is to observe the ViewModel and render the state. That's it.
- ViewModel: The bridge between Model and View. It exposes observable state, handles user interactions, coordinates with services, and contains presentation logic. Crucially, it's framework-agnostic.
Throughout this book, we'll use a real-world greenhouse monitoring system called GreenWatch to demonstrate these patterns. Let's see how MVVM transforms the architecture.
Here's a real ViewModel from the GreenWatch system that manages sensor data:
// File: 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);This ViewModel extends RestfulApiViewModel, which provides reactive state management through RxJS observables. Let's look at what the base classes provide (we'll explore these in detail in Chapter 4: Building Framework-Agnostic Models and Chapter 5: ViewModels and Reactive State):
// File: packages/mvvm-core/src/models/BaseModel.ts (excerpt)
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 readonly schema?: TSchema;
public setData(newData: TData | null): void {
this._data$.next(newData);
}
public validate(data: any): TData {
if (!this.schema) {
return data as TData;
}
return this.schema.parse(data); // Zod validation
}
public dispose(): void {
this._data$.complete();
this._isLoading$.complete();
this._error$.complete();
}
}// File: packages/mvvm-core/src/viewmodels/BaseViewModel.ts (excerpt)
export class BaseViewModel<TModel extends BaseModel<any, any>> {
protected readonly _destroy$ = new Subject<void>();
public readonly data$: Observable<TModel['data']>;
public readonly isLoading$: Observable<boolean>;
public readonly error$: Observable<any>;
public readonly validationErrors$: Observable<ZodError | null>;
protected readonly model: TModel;
constructor(model: TModel) {
this.model = model;
// Expose model observables with automatic cleanup
this.data$ = this.model.data$.pipe(takeUntil(this._destroy$));
this.isLoading$ = this.model.isLoading$.pipe(takeUntil(this._destroy$));
this.error$ = this.model.error$.pipe(takeUntil(this._destroy$));
this.validationErrors$ = this.model.error$.pipe(
map((err) => (err instanceof ZodError ? err : null)),
startWith(null),
takeUntil(this._destroy$),
);
}
public dispose(): void {
this._destroy$.next();
this._destroy$.complete();
}
}Now look at how this same ViewModel is consumed in a React component:
// File: 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 useObservable hook is a simple adapter that bridges RxJS observables to React state:
// File: apps/mvvm-react/src/hooks/useObservable.ts
import { useState, useEffect } from 'react';
import { Observable } from 'rxjs';
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;
}Look at what we've gained:
1. Business logic is testable in isolation. We can instantiate SensorViewModel directly in tests, inject mock services, and verify behavior without rendering anything:
describe("SensorViewModel", () => {
it("exposes sensor data through observables", async () => {
const mockModel = new SensorModel();
const viewModel = new SensorViewModel(mockModel);
// Test the ViewModel without any UI
const sensors = await firstValueFrom(viewModel.data$);
expect(sensors).toBeDefined();
viewModel.dispose();
});
});These tests run in milliseconds. No DOM. No component lifecycle. Just pure logic verification. We'll explore comprehensive testing strategies in Chapter 19: Testing MVVM Applications.
2. The View becomes simple. It observes state and renders. That's all. The SensorList component has zero business logic—it just subscribes to observables and displays data. If you need to change the UI, you're changing the View. If you need to change behavior, you're changing the ViewModel. Clear separation means clear responsibility.
3. Framework portability is real. That same SensorViewModel? It works identically in Vue, Angular, Lit, and even vanilla JavaScript. The business logic is written once and reused everywhere. We'll see this in action across Chapters 8-12, where we implement the same ViewModels in five different frameworks.
4. Collaboration becomes natural. Backend developers can work on Models and domain logic. UI developers can focus on components and styling. Everyone works with a clear contract defined by the ViewModel's public interface.
1.5 What This Book Will Teach You
This isn't a book about React, Vue, or Angular. It's a book about architecture that transcends frameworks.
We're going to build the GreenWatch greenhouse monitoring system using MVVM principles that work across all major frameworks. You'll learn:
-
How to structure TypeScript code for true framework independence. We'll establish patterns using RxJS for reactive state management, dependency injection for testability, and clear boundaries between layers (Chapters 4-7).
-
How to implement ViewModels that drive real UI. Not toy examples—actual, production-quality code that handles asynchronous data, complex state transitions, and user interactions (Chapter 5).
-
How to bind framework-specific Views to framework-agnostic ViewModels. We'll see identical ViewModels powering React components, Vue components, Angular components, Lit web components, and vanilla JavaScript, understanding the patterns that make this possible (Chapters 8-12).
-
How to leverage framework-agnostic patterns. We'll explore reactive state management, event-driven communication, data fetching strategies, headless UI behaviors, and composed UI patterns—all implemented in ways that work with any framework (Chapters 13-17).
-
How to test business logic without touching the DOM. We'll write fast, focused unit tests for ViewModels and integration tests for services, building a test suite that actually gives us confidence (Chapter 19).
-
How to scale this architecture to enterprise systems. We'll explore domain-driven design, plugin architecture, design systems, and performance optimization—all built on the same MVVM foundation (Chapters 18-21).
By the end of this book, you'll have a reusable architectural template that you can apply to any project, regardless of your framework choice. You'll write more maintainable code, ship features faster, and sleep better knowing your architecture won't collapse under its own weight.
1.6 Who This Book Is For
This book assumes you're comfortable with:
- TypeScript fundamentals (types, interfaces, generics, async/await)
- At least one modern framework (React, Vue, or Angular)
- Basic reactive programming concepts (though we'll cover RxJS thoroughly)
- Testing principles (unit tests, mocks, test doubles)
You don't need to be an expert in all frameworks. In fact, if you've only used one, you're the ideal reader—you'll see how architectural patterns let you transfer your knowledge across framework boundaries.
You should read this book if you:
- Find yourself rewriting the same logic in different parts of your application
- Struggle to test components because logic is tangled with UI
- Want to build applications that aren't locked into a single framework
- Feel like your codebase is growing harder to maintain as it scales
- Need to share code between web and mobile applications
- Want to understand how to apply proven architectural patterns to modern frontend development
1.7 A Note on Pragmatism
MVVM isn't a religion. It's a tool. There are scenarios where a simple component with local state is perfectly fine. Not every piece of UI needs a dedicated ViewModel.
The key is intentionality. When you choose to put logic in a component, it should be a conscious decision, not a default. When complexity grows, you should have a clear path to extract that logic into a testable, reusable ViewModel.
This book will give you that path. We'll discuss when to apply MVVM and when simpler patterns suffice. We'll explore the trade-offs honestly, because architecture is about making informed decisions, not following dogma.
1.8 The GreenWatch Domain
Throughout this book, we'll use the GreenWatch greenhouse monitoring system as our primary case study. This is a real application implemented in the Web Loom monorepo, demonstrating MVVM patterns across multiple frameworks.
The Domain Model:
- Greenhouse: A physical greenhouse with environmental zones that need monitoring
- Sensor: Devices that measure environmental conditions (temperature, humidity, soil moisture)
- SensorReading: Time-series data points from sensors
- ThresholdAlert: Alerts triggered when readings exceed configured thresholds
The ViewModels:
GreenHouseViewModel: Manages greenhouse state and operationsSensorViewModel: Manages sensor state and configurationSensorReadingViewModel: Manages sensor reading streams and aggregationsThresholdAlertViewModel: Manages alert configuration and notifications
The Implementations:
apps/mvvm-react: React implementation with hooksapps/mvvm-vue: Vue 3 implementation with Composition APIapps/mvvm-angular: Angular implementation with dependency injectionapps/mvvm-lit: Lit web components implementationapps/mvvm-vanilla: Vanilla JavaScript with EJS templates
All of these implementations share the same ViewModels and Models. The business logic is written once and reused everywhere. This is the power of MVVM.
Let's fix frontend architecture. Together, we'll revive the patterns that work, adapt them to modern JavaScript/TypeScript development, and build applications that we're proud to maintain for years to come.
The crisis is real, but the solution is within reach. Let's begin.