Chapter 10: Angular Implementation with DI
In the previous two chapters, we explored how React's hooks and Vue's Composition API integrate with MVVM architecture. Both frameworks required a small bridge utility (useSignal) to mirror @web-loom/signals-core signals into their respective reactive systems. Now we turn to Angular—a framework that, since version 16+, has grown its own first-class Signals primitive. That turns out to be an unusually good match for signals-based ViewModels.
Angular's own signal()/computed() APIs are conceptually the same idea as @web-loom/signals-core: a readable, subscribable container of a value that a template can read directly. The two aren't the same implementation, so we still need a small bridge—fromLoomSignal—to mirror a Web Loom signal into a native Angular Signal. But because both sides are signals rather than one side being an RxJS Observable, that bridge is a handful of lines with no operators, no pipes, and no async keyword to reason about. Combined with Angular's dependency injection system, Angular provides a clean, low-ceremony MVVM integration.
We'll continue using the GreenWatch greenhouse monitoring system, extracting real implementations from apps/mvvm-angular/ in the Web Loom monorepo. By the end of this chapter, you'll understand how to build Angular applications with MVVM architecture—and you'll see that the same ViewModels work identically across React, Vue, and Angular without any modifications.
10.1 The Angular-MVVM Integration Advantage
Angular components need to:
- Read ViewModel signals
- Trigger change detection when signal values change
- Clean up subscriptions when components are destroyed
- Execute ViewModel commands in response to user actions
Like React and Vue, Angular needs a small bridge to bring a @web-loom/signals-core ReadonlySignal into its own reactivity system. Unlike React and Vue, that bridge target is itself a signal—Angular's native Signal<T>—so the bridge is symmetrical rather than an adapter between two different paradigms:
fromLoomSignal: A tiny utility that mirrors a Web Loom signal into a native AngularSignal, tearing down the subscription viaDestroyRefwhen the component is destroyed- Signal Reads in Templates: Angular templates call a signal as a function (
data()) to read its current value and register the template as a dependent—no pipe needed - Dependency Injection: Provides ViewModels to components through Angular's DI system
- InjectionTokens: Type-safe tokens for registering and injecting ViewModels
This makes Angular a very natural fit for MVVM with signals-based ViewModels—arguably more so than React or Vue, since Angular's template layer already understands "signal" as a concept.
10.2 Dependency Injection with InjectionTokens
Angular's dependency injection system is one of its most powerful features. To provide ViewModels to components, we use InjectionToken—a type-safe way to register dependencies that aren't classes.
Here's how we create an injection token for the SensorViewModel:
// apps/mvvm-angular/src/app/components/sensor-list/sensor-list.component.ts
import { Component, OnInit, Inject, InjectionToken, Signal, DestroyRef, inject } from '@angular/core';
import { fromLoomSignal } from '../../utils/loom-signals';
import { CommonModule } from '@angular/common';
import { sensorViewModel, SensorListData, SensorViewModel } from '@repo/view-models/SensorViewModel';
import { RouterLink } from '@angular/router';
// Create an injection token for the sensor view model
export const SENSOR_VIEW_MODEL = new InjectionToken<SensorViewModel>('SensorViewModel');
@Component({
selector: 'app-sensor-list',
standalone: true,
imports: [CommonModule, BackIconComponent, RouterLink],
providers: [
{
provide: SENSOR_VIEW_MODEL,
useValue: sensorViewModel,
},
],
templateUrl: './sensor-list.component.html',
styleUrl: './sensor-list.component.scss',
})
export class SensorListComponent implements OnInit {
public data$!: Signal<SensorListData | null>;
public loading$!: Signal<boolean>;
public error$!: Signal<any>;
private destroyRef = inject(DestroyRef);
constructor(@Inject(SENSOR_VIEW_MODEL) public readonly vm: SensorViewModel) {
// Constructor only for dependency injection
}
ngOnInit(): void {
// Bridge the ViewModel's signals into native Angular signals
this.data$ = fromLoomSignal(this.vm.data$, this.destroyRef);
this.loading$ = fromLoomSignal(this.vm.isLoading$, this.destroyRef);
this.error$ = fromLoomSignal(this.vm.error$, this.destroyRef);
// Execute commands and side effects
this.vm.fetchCommand.execute();
}
getStatusClass(status: string | undefined): string {
if (!status) return '';
switch (status.toLowerCase()) {
case 'online':
return 'status-online';
case 'offline':
return 'status-offline';
case 'error':
return 'status-error';
default:
return '';
}
}
}Let's break down the dependency injection pattern:
1. InjectionToken Creation:
export const SENSOR_VIEW_MODEL = new InjectionToken<SensorViewModel>('SensorViewModel');This creates a type-safe token that Angular's DI system can use to identify the dependency. The generic type <SensorViewModel> ensures type safety when injecting.
2. Provider Configuration:
providers: [
{
provide: SENSOR_VIEW_MODEL,
useValue: sensorViewModel,
},
]The providers array tells Angular how to resolve the token. useValue provides the actual ViewModel instance (the singleton sensorViewModel from @repo/view-models).
3. Constructor Injection:
constructor(@Inject(SENSOR_VIEW_MODEL) public readonly vm: SensorViewModel) {
// Constructor only for dependency injection
}The @Inject decorator tells Angular to inject the dependency associated with SENSOR_VIEW_MODEL. The public readonly modifier makes vm accessible in the template.
4. Signal Bridging:
ngOnInit(): void {
this.data$ = fromLoomSignal(this.vm.data$, this.destroyRef);
this.loading$ = fromLoomSignal(this.vm.isLoading$, this.destroyRef);
this.error$ = fromLoomSignal(this.vm.error$, this.destroyRef);
this.vm.fetchCommand.execute();
}In ngOnInit, we bridge the ViewModel's @web-loom/signals-core signals into native Angular signals and execute the initial fetch command. The $ suffix on data$/loading$/error$ is a naming convention carried over from the ViewModel layer—these are now native Angular Signal<T> values, called as functions (data$()) in the template, not Observables.
10.3 fromLoomSignal: Bridging Web Loom Signals to Angular Signals
fromLoomSignal is Angular's equivalent of React's useSignal hook or Vue's useSignal composable. It reads a @web-loom/signals-core signal's current value, mirrors it into a native Angular signal(), and keeps the two in sync until the component is destroyed. Here's the complete implementation:
// apps/mvvm-angular/src/app/utils/loom-signals.ts
import { signal, type Signal, DestroyRef, inject } from '@angular/core';
import type { ReadonlySignal } from '@web-loom/signals-core';
export function fromLoomSignal<T>(source: ReadonlySignal<T>, destroyRef?: DestroyRef): Signal<T> {
const mirror = signal<T>(source.peek());
const unsubscribe = source.subscribe((value) => mirror.set(value));
(destroyRef ?? inject(DestroyRef)).onDestroy(unsubscribe);
return mirror.asReadonly();
}1. Seed with the current value: source.peek() reads the Web Loom signal's value synchronously, without subscribing, to seed the native Angular signal.
2. Subscribe for future changes: source.subscribe(...) registers a callback for every future change, calling mirror.set(value) to push the new value into the Angular signal.
3. Teardown via DestroyRef: destroyRef.onDestroy(unsubscribe) ensures the subscription is cleaned up when the component (or the injection context fromLoomSignal was called from) is destroyed—there's no ngOnDestroy boilerplate to write by hand.
4. Return a readonly view: mirror.asReadonly() prevents template code from accidentally calling .set() on what should be a one-way bridge.
Here's the template for SensorListComponent, using the bridged signals:
<!-- apps/mvvm-angular/src/app/components/sensor-list/sensor-list.component.html -->
<a routerLink="/" class="back-button">
<back-icon> </back-icon>
</a>
<div class="card">
<h2 class="card-title">Sensor List</h2>
<ul class="list" *ngIf="data$() as sensors">
<li *ngFor="let sensor of sensors" class="list-item">
<h3>{{ sensor.status }} ({{ sensor.id }})</h3>
<p>Type:{{ sensor.type }}</p>
<p>Greenhouse: {{ sensor.greenhouse.name }}</p>
<p>
Status:
<span [ngClass]="getStatusClass(sensor.status)">{{ sensor.status }}</span>
</p>
</li>
</ul>
<p *ngIf="loading$()">Loading.....</p>
</div>Let's examine the key patterns:
1. Signal Read with Alias:
<ul class="list" *ngIf="data$() as sensors">Calling data$() reads the current value of the bridged Angular signal and registers this template expression as a dependent, so it re-evaluates whenever the signal changes. *ngIf assigns the result to sensors and only renders the list when it's truthy (not null).
*2. Iteration with ngFor:
<li *ngFor="let sensor of sensors" class="list-item">Once we have the sensors array, we iterate over it with *ngFor—standard Angular template syntax, unchanged from before.
3. Loading State:
<p *ngIf="loading$()">Loading.....</p>loading$ is also a bridged signal, read the same way—call it as a function.
4. No Manual Cleanup:
Notice there's no ngOnDestroy or manual unsubscribe call in the component. fromLoomSignal already registered its own teardown with DestroyRef when it was called.
10.3.1 Comparing with React and Vue
Let's compare how the three frameworks handle signal consumption:
React (useSignal hook):
const sensors = useSignal(sensorViewModel.data$);
const isLoading = useSignal(sensorViewModel.isLoading$);
// useSignal internally:
// - Uses useSyncExternalStore(sig.subscribe, sig.get, sig.get)
// - React handles subscribe/unsubscribe timingVue (useSignal composable):
const sensors = useSignal(sensorViewModel.data$);
const isLoading = useSignal(sensorViewModel.isLoading$);
// useSignal internally:
// - Seeds a shallowRef with sig.peek()
// - Subscribes via observe(), unsubscribes in onUnmountedAngular (fromLoomSignal + native signal reads):
data$ = fromLoomSignal(sensorViewModel.data$, this.destroyRef);
loading$ = fromLoomSignal(sensorViewModel.isLoading$, this.destroyRef);<ul *ngIf="data$() as sensors">
<li *ngFor="let sensor of sensors">...</li>
</ul>
<p *ngIf="loading$()">Loading...</p>All three follow the same shape: seed with the current value, subscribe for future changes, tear down on unmount/destroy. Angular's version reads slightly more explicitly (fromLoomSignal in the component class, a plain function call in the template) rather than hiding the bridge entirely inside a hook, but the mental model—and the amount of code you write per ViewModel property—is essentially the same across all three frameworks now.
10.4 Building the Greenhouse List: CRUD Operations with ViewModels
The Greenhouse List component demonstrates a more complex scenario: a form-driven CRUD interface that creates, updates, and deletes greenhouses through the GreenHouseViewModel. This showcases how Angular's reactive forms integrate with ViewModel commands.
Here's the complete component implementation:
// apps/mvvm-angular/src/app/components/greenhouse-list/greenhouse-list.component.ts
import { Component, OnInit, Inject, InjectionToken, Signal, DestroyRef, inject } from '@angular/core';
import { fromLoomSignal } from '../../utils/loom-signals';
import { observe } from '@web-loom/signals-core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { GreenhouseData, greenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
import { RouterLink } from '@angular/router';
export const GREENHOUSE_VIEW_MODEL = new InjectionToken<typeof greenHouseViewModel>('GREENHOUSE_VIEW_MODEL');
@Component({
selector: 'app-greenhouse-list',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, BackIconComponent, RouterLink],
templateUrl: './greenhouse-list.component.html',
styleUrls: ['./greenhouse-list.component.scss'],
providers: [
{
provide: GREENHOUSE_VIEW_MODEL,
useValue: greenHouseViewModel,
},
],
})
export class GreenhouseListComponent implements OnInit {
public vm: typeof greenHouseViewModel;
public greenhouses$!: Signal<GreenhouseData[] | null>;
public loading$!: Signal<boolean>;
public error$!: Signal<any>;
greenhouseForm: FormGroup;
editingGreenhouseId: string | null | undefined = null;
greenhouses: GreenhouseData[] = [];
private destroyRef = inject(DestroyRef);
greenHouseSizeOptions = ['25sqm', '50sqm', '100sqm'] as const;
constructor(
private fb: FormBuilder,
@Inject(GREENHOUSE_VIEW_MODEL) vm: typeof greenHouseViewModel,
) {
this.vm = vm;
this.greenhouseForm = this.fb.group({
name: ['', Validators.required],
location: ['', Validators.required],
size: ['', Validators.required],
cropType: [''],
id: [''], // Optional field for editing
});
}
ngOnInit(): void {
this.greenhouses$ = fromLoomSignal(this.vm.data$, this.destroyRef);
this.loading$ = fromLoomSignal(this.vm.isLoading$, this.destroyRef);
this.error$ = fromLoomSignal(this.vm.error$, this.destroyRef);
this.vm.fetchCommand.execute();
this.destroyRef.onDestroy(observe(this.vm.data$, (ghs) => (this.greenhouses = ghs || [])));
}
handleSubmit(): void {
if (this.greenhouseForm.invalid) {
console.error('Form is invalid');
return;
}
const formDataValue = this.greenhouseForm.value;
if (this.editingGreenhouseId) {
const existingGreenhouse = this.greenhouses.find((gh) => gh.id === this.editingGreenhouseId);
if (existingGreenhouse) {
this.vm.updateCommand.execute({
id: this.editingGreenhouseId,
payload: {
...existingGreenhouse,
name: formDataValue.name,
location: formDataValue.location,
size: formDataValue.size,
cropType: formDataValue.cropType,
},
});
}
} else {
this.vm.createCommand.execute(formDataValue);
}
this.greenhouseForm.reset();
this.editingGreenhouseId = null;
}
handleUpdateForm(id?: string): void {
if (!id) return;
const greenhouse = this.greenhouses.find((gh) => gh.id === id);
if (greenhouse) {
this.editingGreenhouseId = greenhouse.id;
this.greenhouseForm.patchValue({
name: greenhouse.name,
location: greenhouse.location,
size: greenhouse.size,
cropType: greenhouse.cropType || '',
});
}
}
handleDelete(id?: string): void {
if (!id) {
console.error('No ID provided for deletion');
return;
}
this.vm.deleteCommand.execute(id);
if (this.editingGreenhouseId === id) {
this.greenhouseForm.reset();
this.editingGreenhouseId = null;
}
}
}10.4.1 Pattern Analysis: Angular Forms with ViewModels
Let's examine the key patterns in this component:
1. Reactive Forms Setup:
this.greenhouseForm = this.fb.group({
name: ['', Validators.required],
location: ['', Validators.required],
size: ['', Validators.required],
cropType: [''],
});Angular's FormBuilder creates a reactive form with validation rules. This is pure Angular—no MVVM-specific code here.
2. Signal Bridging Plus a Local Mirror:
this.greenhouses$ = fromLoomSignal(this.vm.data$, this.destroyRef);
// ...
this.destroyRef.onDestroy(observe(this.vm.data$, (ghs) => (this.greenhouses = ghs || [])));We bridge data$ for the template (fromLoomSignal) and, separately, use observe() to maintain a plain local array (this.greenhouses) for the edit/delete handlers, which need synchronous array access outside of a template read. observe() calls its callback immediately with the current value and then on every future change—the signals-core equivalent of what a BehaviorSubject subscription would have given you. It returns a plain unsubscribe function, which we hand straight to destroyRef.onDestroy(...).
3. ViewModel Command Execution:
// Create
this.vm.createCommand.execute(formDataValue);
// Update
this.vm.updateCommand.execute({
id: this.editingGreenhouseId,
payload: { ...existingGreenhouse, ...formDataValue },
});
// Delete
this.vm.deleteCommand.execute(id);All CRUD operations go through ViewModel commands. The component doesn't know about HTTP requests, API endpoints, or data persistence—that's all encapsulated in the ViewModel.
4. Form State Management:
handleUpdateForm(id?: string): void {
const greenhouse = this.greenhouses.find((gh) => gh.id === id);
if (greenhouse) {
this.editingGreenhouseId = greenhouse.id;
this.greenhouseForm.patchValue({
name: greenhouse.name,
location: greenhouse.location,
size: greenhouse.size,
cropType: greenhouse.cropType || '',
});
}
}When editing, we populate the form with existing data using patchValue. The editingGreenhouseId tracks whether we're in create or update mode.
Here's the corresponding template:
<!-- apps/mvvm-angular/src/app/components/greenhouse-list/greenhouse-list.component.html -->
<a routerLink="/" class="back-button">
<back-icon></back-icon>
</a>
<section class="flex-container flex-row">
<form [formGroup]="greenhouseForm" (ngSubmit)="handleSubmit()" class="form-container">
<div class="form-group">
<label for="name">Greenhouse Name:</label>
<input
type="text"
id="name"
formControlName="name"
required
class="input-field"
placeholder="Enter greenhouse name"
/>
<div
*ngIf="
greenhouseForm.get('name')?.invalid &&
(greenhouseForm.get('name')?.dirty || greenhouseForm.get('name')?.touched)
"
class="error-message"
>
Name is required.
</div>
</div>
<div class="form-group">
<label for="location">Location:</label>
<textarea
id="location"
formControlName="location"
required
rows="3"
class="textarea-field"
placeholder="Location"
></textarea>
<div
*ngIf="
greenhouseForm.get('location')?.invalid &&
(greenhouseForm.get('location')?.dirty || greenhouseForm.get('location')?.touched)
"
class="error-message"
>
Location is required.
</div>
</div>
<div class="form-group">
<label for="size">Size:</label>
<select id="size" formControlName="size" class="select-field" required>
<option value="">Select size</option>
<option value="25sqm">25sqm / Small</option>
<option value="50sqm">50sqm / Medium</option>
<option value="100sqm">100sqm / Large</option>
</select>
</div>
<div class="form-group">
<label for="cropType">Crop Type:</label>
<input type="text" id="cropType" formControlName="cropType" class="input-field" placeholder="Enter crop type" />
</div>
<button type="submit" class="button" [disabled]="greenhouseForm.invalid">Submit</button>
</form>
<div class="card" style="max-width: 600px">
<h1 class="card-title">Greenhouses</h1>
<div *ngIf="loading$()" class="card-content">
<p>Loading greenhouses...</p>
</div>
<ng-container *ngIf="greenhouses$() as greenhouseList">
<div *ngIf="greenhouseList && greenhouseList.length > 0; else noGreenhouses">
<ul class="card-content list">
<li
*ngFor="let gh of greenhouseList"
class="list-item"
style="font-size: 1.8rem; justify-content: space-between"
>
<span>{{ gh.name }}</span>
<div class="button-group">
<button class="button-tiny button-tiny-delete" (click)="handleDelete(gh?.id)">Delete</button>
<button class="button-tiny button-tiny-edit" (click)="handleUpdateForm(gh?.id)">Edit</button>
</div>
</li>
</ul>
</div>
</ng-container>
<ng-template #noGreenhouses>
<p *ngIf="!(loading$())" class="card-content">No greenhouses found.</p>
</ng-template>
</div>
</section>The template demonstrates several Angular-specific patterns:
1. Reactive Forms Binding:
<form [formGroup]="greenhouseForm" (ngSubmit)="handleSubmit()">
<input formControlName="name" />
</form>The [formGroup] directive binds the form to the FormGroup instance. Individual controls use formControlName to bind to form fields.
2. Validation Display:
<div
*ngIf="
greenhouseForm.get('name')?.invalid &&
(greenhouseForm.get('name')?.dirty || greenhouseForm.get('name')?.touched)
"
class="error-message"
>
Name is required.
</div>Angular's reactive forms provide rich validation state. We show errors only when the field is invalid AND has been interacted with.
3. Signal Read with ng-container:
<ng-container *ngIf="greenhouses$() as greenhouseList">
<div *ngIf="greenhouseList && greenhouseList.length > 0; else noGreenhouses">
<ul>
<li *ngFor="let gh of greenhouseList">...</li>
</ul>
</div>
</ng-container>The ng-container is a logical container that doesn't render to the DOM. We use it with a signal read (greenhouses$()) to unwrap the current value, then check if the list has items.
4. Event Binding to ViewModel Commands:
<button (click)="handleDelete(gh?.id)">Delete</button>
<button (click)="handleUpdateForm(gh?.id)">Edit</button>Click events call component methods, which in turn execute ViewModel commands. The component acts as a thin adapter between the template and the ViewModel.
10.5 Building the Dashboard: Component Composition
The GreenWatch Dashboard in Angular demonstrates a different architectural approach compared to React and Vue. Instead of managing multiple ViewModels in a single component, Angular's Dashboard delegates to child components, each responsible for its own ViewModel.
Here's the Dashboard component:
// apps/mvvm-angular/src/app/components/dashboard/dashboard.component.ts
import { Component } from '@angular/core';
import { GreenhouseCardComponent } from '../greenhouse-card/greenhouse-card.component';
import { SensorCardComponent } from '../sensor-card/sensor-card.component';
import { SensorReadingCardComponent } from '../sensor-reading-card/sensor-reading-card.component';
import { ThresholdAlertCardComponent } from '../threshold-alert-card/threshold-alert-card.component';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
GreenhouseCardComponent,
SensorCardComponent,
SensorReadingCardComponent,
ThresholdAlertCardComponent
],
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.scss',
})
export class DashboardComponent {}And the template:
<!-- apps/mvvm-angular/src/app/components/dashboard/dashboard.component.html -->
<div>
<h2>Dashboard</h2>
<div class="flex-container">
<div class="flex-item">
<app-greenhouse-card></app-greenhouse-card>
</div>
<div class="flex-item">
<app-sensor-card></app-sensor-card>
</div>
<div class="flex-item">
<app-threshold-alert-card></app-threshold-alert-card>
</div>
<div class="flex-item">
<app-sensor-reading-card></app-sensor-reading-card>
</div>
</div>
</div>Notice how simple this is! The Dashboard component has no logic—it's purely a layout container. Each card component manages its own ViewModel through dependency injection.
Here's one of the card components:
// apps/mvvm-angular/src/app/components/greenhouse-card/greenhouse-card.component.ts
import { Component, OnInit, Inject, InjectionToken, Signal, DestroyRef, inject } from '@angular/core';
import { fromLoomSignal } from '../../utils/loom-signals';
import { RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common';
import { greenHouseViewModel, GreenhouseListData } from '@repo/view-models/GreenHouseViewModel';
export const GREENHOUSE_VIEW_MODEL = new InjectionToken<typeof greenHouseViewModel>('GREENHOUSE_VIEW_MODEL');
@Component({
selector: 'app-greenhouse-card',
standalone: true,
imports: [RouterModule, CommonModule],
templateUrl: './greenhouse-card.component.html',
styleUrl: './greenhouse-card.component.scss',
providers: [
{
provide: GREENHOUSE_VIEW_MODEL,
useValue: greenHouseViewModel,
},
],
})
export class GreenhouseCardComponent implements OnInit {
public vm: typeof greenHouseViewModel;
public data$!: Signal<GreenhouseListData | null>;
public loading$!: Signal<boolean>;
public error$!: Signal<any>;
private destroyRef = inject(DestroyRef);
constructor(@Inject(GREENHOUSE_VIEW_MODEL) vm: typeof greenHouseViewModel) {
this.vm = vm;
}
ngOnInit(): void {
this.data$ = fromLoomSignal(this.vm.data$, this.destroyRef);
this.loading$ = fromLoomSignal(this.vm.isLoading$, this.destroyRef);
this.error$ = fromLoomSignal(this.vm.error$, this.destroyRef);
this.vm.fetchCommand.execute();
}
}And its template:
<!-- apps/mvvm-angular/src/app/components/greenhouse-card/greenhouse-card.component.html -->
<div class="card">
<div class="card-title">
<a routerLink="/greenhouses">Greenhouses</a>
</div>
<div class="card-content">
<p>Total: {{ (data$())?.length }}</p>
</div>
</div>10.5.1 Comparing Dashboard Architectures
Let's compare how the three frameworks structure the Dashboard:
React (Centralized ViewModel Management):
const Dashboard: React.FC = () => {
// All ViewModels managed in parent component
const greenHouses = useSignal(greenHouseViewModel.data$);
const sensors = useSignal(sensorViewModel.data$);
const sensorReadings = useSignal(sensorReadingViewModel.data$);
const thresholdAlerts = useSignal(thresholdAlertViewModel.data$);
useEffect(() => {
// Fetch all data in parent
greenHouseViewModel.fetchCommand.execute();
sensorViewModel.fetchCommand.execute();
// ...
}, []);
return (
<div>
<GreenhouseCard greenHouses={greenHouses} />
<SensorCard sensors={sensors} />
{/* Props passed down to children */}
</div>
);
};Vue (Centralized ViewModel Management):
<script setup lang="ts">
// All ViewModels managed in parent component
const greenHouses = useSignal(greenHouseViewModel.data$);
const sensors = useSignal(sensorViewModel.data$);
const sensorReadings = useSignal(sensorReadingViewModel.data$);
const thresholdAlerts = useSignal(thresholdAlertViewModel.data$);
onMounted(() => {
// Fetch all data in parent
greenHouseViewModel.fetchCommand.execute();
sensorViewModel.fetchCommand.execute();
// ...
});
</script>
<template>
<div>
<GreenhouseCard :greenhouse-list-data-prop="greenHouses" />
<SensorCard :sensor-list-data-prop="sensors" />
<!-- Props passed down to children -->
</div>
</template>Angular (Decentralized with DI):
@Component({
selector: 'app-dashboard',
template: `
<div>
<app-greenhouse-card></app-greenhouse-card>
<app-sensor-card></app-sensor-card>
<!-- Each child manages its own ViewModel via DI -->
</div>
`
})
export class DashboardComponent {}
// Each card component:
@Component({
selector: 'app-greenhouse-card',
providers: [
{ provide: GREENHOUSE_VIEW_MODEL, useValue: greenHouseViewModel }
]
})
export class GreenhouseCardComponent {
data$!: Signal<GreenhouseListData | null>;
private destroyRef = inject(DestroyRef);
constructor(@Inject(GREENHOUSE_VIEW_MODEL) public vm: typeof greenHouseViewModel) {}
ngOnInit() {
this.data$ = fromLoomSignal(this.vm.data$, this.destroyRef);
this.vm.fetchCommand.execute();
}
}Key Differences:
- Data Flow: React and Vue pass data down as props; Angular uses dependency injection
- Responsibility: React and Vue centralize ViewModel management in the parent; Angular distributes it to children
- Coupling: React and Vue create parent-child coupling through props; Angular components are more independent
- Testability: Angular's DI makes it easier to mock ViewModels in child component tests
Both approaches are valid. Angular's DI approach scales better for large applications with deep component trees, while React/Vue's prop-passing approach makes data flow more explicit.
10.6 Three-Way Framework Comparison
Now that we've seen MVVM implementations in React, Vue, and Angular, let's compare them side by side across key dimensions:
10.6.1 Signal Integration
| Framework | Approach | Boilerplate | Automatic Cleanup |
|-----------|----------|-------------|-------------------|
| React | useSignal hook (useSyncExternalStore) | Low | Yes (managed by React) |
| Vue | useSignal composable (shallowRef + observe) | Low | Yes (via onUnmounted) |
| Angular | fromLoomSignal bridge + native Signal reads | Low | Yes (via DestroyRef) |
Result: All three converge on roughly the same shape now that ViewModels are signals-based—a small bridge utility, called once per property, with automatic cleanup. Angular no longer needs the "native RxJS" advantage it once had, because none of the frameworks are bridging RxJS anymore.
10.6.2 ViewModel Injection
| Framework | Approach | Type Safety | Testability | |-----------|----------|-------------|-------------| | React | Direct import or Context | Manual | Good (can mock imports) | | Vue | Direct import or provide/inject | Manual | Good (can mock imports) | | Angular | InjectionToken + DI | Built-in | Excellent (DI system) |
Winner: Angular. The DI system provides superior type safety and testability, independent of the reactivity layer.
10.6.3 Template Syntax
| Framework | Approach | Verbosity | Type Safety | |-----------|----------|-----------|--------------| | React | JSX with JavaScript expressions | Low | Excellent (TypeScript) | | Vue | Template with directives | Medium | Good (with TypeScript) | | Angular | Template with directives | High | Good (with TypeScript) |
Winner: React. JSX is the most concise and has the best TypeScript integration.
10.6.4 Form Handling
| Framework | Approach | Validation | Integration with MVVM | |-----------|----------|------------|----------------------| | React | Controlled components or libraries | Manual or library | Good | | Vue | v-model or libraries | Manual or library | Good | | Angular | Reactive Forms | Built-in | Excellent |
Winner: Angular. Reactive Forms provide powerful built-in validation and state management.
10.6.5 Learning Curve
| Framework | MVVM-Specific Complexity | Overall Complexity |
|-----------|--------------------------|---------------------|
| React | Low (just useSignal) | Low |
| Vue | Low (just useSignal) | Low |
| Angular | Low-Medium (fromLoomSignal + DI) | Medium |
Winner: Roughly tied. Now that all three frameworks are bridging the same signals API, Angular's remaining extra complexity comes from its DI system and module setup, not from the reactivity bridge—which used to be its steepest MVVM-specific cost when it meant learning RxJS operators.
10.6.6 Code Example: Same ViewModel, Three Frameworks
Here's the same functionality—displaying a list of sensors—implemented in all three frameworks:
React:
const SensorList: React.FC = () => {
const sensors = useSignal(sensorViewModel.data$);
const loading = useSignal(sensorViewModel.isLoading$);
useEffect(() => {
sensorViewModel.fetchCommand.execute();
}, []);
if (loading) return <p>Loading...</p>;
return (
<ul>
{sensors.map(sensor => (
<li key={sensor.id}>{sensor.status}</li>
))}
</ul>
);
};Vue:
<script setup lang="ts">
import { onMounted } from 'vue';
import { useSignal } from '../hooks/useSignal';
import { sensorViewModel } from '@repo/view-models/SensorViewModel';
const sensors = useSignal(sensorViewModel.data$);
const loading = useSignal(sensorViewModel.isLoading$);
onMounted(() => {
sensorViewModel.fetchCommand.execute();
});
</script>
<template>
<p v-if="loading">Loading...</p>
<ul v-else>
<li v-for="sensor in sensors" :key="sensor.id">
{{ sensor.status }}
</li>
</ul>
</template>Angular:
@Component({
selector: 'app-sensor-list',
template: `
<p *ngIf="loading$()">Loading...</p>
<ul *ngIf="data$() as sensors">
<li *ngFor="let sensor of sensors">
{{ sensor.status }}
</li>
</ul>
`,
providers: [
{ provide: SENSOR_VIEW_MODEL, useValue: sensorViewModel }
]
})
export class SensorListComponent implements OnInit {
data$!: Signal<SensorListData | null>;
loading$!: Signal<boolean>;
private destroyRef = inject(DestroyRef);
constructor(@Inject(SENSOR_VIEW_MODEL) public vm: SensorViewModel) {}
ngOnInit() {
this.data$ = fromLoomSignal(this.vm.data$, this.destroyRef);
this.loading$ = fromLoomSignal(this.vm.isLoading$, this.destroyRef);
this.vm.fetchCommand.execute();
}
}Observations:
- All three use the exact same ViewModel (
sensorViewModel) - React and Vue are more concise (fewer lines of code)
- Angular requires more setup (InjectionToken, providers, ngOnInit) but provides better DI
- All three bridges are equally small—the days of Angular's async pipe being uniquely terse compared to a hand-rolled
useObservablehook are gone, since neither side is RxJS anymore - The business logic is identical across all three—only the View layer differs
10.7 Angular-Specific MVVM Advantages
While all three frameworks work well with MVVM, Angular still has some unique advantages:
10.7.1 Native Signal Integration
Angular's own Signals system (stable since Angular 17) means:
- Symmetrical bridge:
fromLoomSignalmirrors one signal implementation into another, rather than adapting a push-based stream into a pull-based one - Template-level signal reads: Calling
data$()in a template is a first-class Angular pattern, not a workaround - Consistent mental model: Both mvvm-core's
ReadonlySignaland Angular'sSignalshare the same "readable, subscribable value container" shape - Performance: Angular's fine-grained change detection (zoneless mode included) is designed around exactly this kind of signal read
10.7.2 Dependency Injection System
Angular's DI system provides:
- Type-safe injection: InjectionTokens ensure type safety
- Hierarchical injectors: Different components can have different ViewModel instances
- Testing support: Easy to mock dependencies in tests
- Lazy loading: ViewModels can be provided at route level for code splitting
10.7.3 Reactive Forms
Angular's Reactive Forms integrate naturally with MVVM:
- Signal-friendly: Form value changes can be read directly or bridged into signals as needed
- Built-in validation: Rich validation with error messages
- Type-safe: FormGroup types match your data models
- Composable: Forms can be nested and composed
10.7.4 Standalone Components
Angular's standalone components (introduced in Angular 14+) make MVVM even cleaner:
- No NgModules needed: Components declare their own dependencies
- Simpler DI: Providers are declared directly on components
- Better tree-shaking: Unused code is eliminated more effectively
- Easier testing: Components are more self-contained
All the examples in this chapter use standalone components, which is the recommended approach for new Angular applications.
10.8 Common Patterns and Best Practices
Based on the GreenWatch Angular implementation, here are some best practices for Angular MVVM:
10.8.1 Use InjectionTokens for ViewModels
Always create InjectionTokens for ViewModels rather than injecting them directly:
// ✅ Good: Type-safe injection token
export const SENSOR_VIEW_MODEL = new InjectionToken<SensorViewModel>('SensorViewModel');
@Component({
providers: [
{ provide: SENSOR_VIEW_MODEL, useValue: sensorViewModel }
]
})
export class SensorListComponent {
constructor(@Inject(SENSOR_VIEW_MODEL) public vm: SensorViewModel) {}
}
// ❌ Bad: Direct injection without token
// This doesn't work because ViewModels aren't classes10.8.2 Bridge Signals in ngOnInit
Always bridge ViewModel signals in ngOnInit, not in the constructor:
// ✅ Good: Bridge in ngOnInit
ngOnInit(): void {
this.data$ = fromLoomSignal(this.vm.data$, this.destroyRef);
this.loading$ = fromLoomSignal(this.vm.isLoading$, this.destroyRef);
this.vm.fetchCommand.execute();
}
// ❌ Bad: Bridge in constructor
constructor(@Inject(SENSOR_VIEW_MODEL) public vm: SensorViewModel) {
this.data$ = fromLoomSignal(this.vm.data$); // Too early, and DI context is ambiguous!
}The constructor should only be used for dependency injection. All initialization logic belongs in ngOnInit.
10.8.3 Prefer fromLoomSignal Over Manual Subscriptions
Use fromLoomSignal whenever a ViewModel signal needs to reach a template:
// ✅ Good: fromLoomSignal handles the subscription
data$ = fromLoomSignal(this.vm.data$, this.destroyRef);<ul *ngIf="data$() as sensors">
<li *ngFor="let sensor of sensors">{{ sensor.status }}</li>
</ul>// ❌ Bad: Manual subscription in component
sensors: Sensor[] = [];
ngOnInit() {
const unsubscribe = this.vm.data$.subscribe(data => {
this.sensors = data;
});
this.destroyRef.onDestroy(unsubscribe); // easy to forget
}Manual subscriptions require you to remember teardown yourself. fromLoomSignal registers it for you.
10.8.4 Use observe() for Side Effects Outside the Template
When you need a plain, synchronously-updated local variable (not a template-facing signal)—for example, to look up an item by ID in a click handler—use observe():
// ✅ Good: observe() for a local mirror used outside the template
this.destroyRef.onDestroy(
observe(this.vm.data$, (ghs) => (this.greenhouses = ghs || []))
);
// ❌ Bad: reaching for fromLoomSignal + a manual effect just to get a plain arrayobserve() fires immediately with the current value and then on every future change, and returns a plain unsubscribe function—hand it straight to destroyRef.onDestroy(...).
10.8.5 Provide ViewModels at Component Level
Provide ViewModels in the component's providers array, not at the module or root level:
// ✅ Good: Component-level provider
@Component({
selector: 'app-sensor-list',
providers: [
{ provide: SENSOR_VIEW_MODEL, useValue: sensorViewModel }
]
})
export class SensorListComponent {}
// ❌ Bad: Root-level provider
// This creates a single instance shared across the entire appComponent-level providers ensure each component gets its own ViewModel instance (if needed) and make testing easier.
10.8.6 Keep Components Thin
Components should be thin adapters between templates and ViewModels:
// ✅ Good: Thin component
export class SensorListComponent implements OnInit {
data$!: Signal<SensorListData | null>;
loading$!: Signal<boolean>;
private destroyRef = inject(DestroyRef);
constructor(@Inject(SENSOR_VIEW_MODEL) public vm: SensorViewModel) {}
ngOnInit() {
this.data$ = fromLoomSignal(this.vm.data$, this.destroyRef);
this.loading$ = fromLoomSignal(this.vm.isLoading$, this.destroyRef);
this.vm.fetchCommand.execute();
}
}
// ❌ Bad: Business logic in component
export class SensorListComponent {
sensors: Sensor[] = [];
async loadSensors() {
const response = await fetch('/api/sensors');
this.sensors = await response.json();
// Business logic belongs in ViewModel!
}
}All business logic should live in ViewModels. Components should only handle framework-specific concerns (DI, lifecycle, template binding).
10.9 Key Takeaways
Let's summarize what we've learned about Angular MVVM implementation:
1. Signals Meet Signals: Angular's own Signals system and @web-loom/signals-core are conceptually the same idea, so fromLoomSignal is a small, symmetrical bridge rather than an adapter between two different paradigms.
2. fromLoomSignal Eliminates Boilerplate: fromLoomSignal seeds a native Angular signal from the current value and keeps it in sync, tearing itself down via DestroyRef. Templates read the result by calling it as a function—no pipe needed.
3. Dependency Injection for ViewModels: InjectionTokens provide type-safe, testable ViewModel injection. Angular's DI system is more sophisticated than React Context or Vue's provide/inject.
4. Reactive Forms Integration: Angular's Reactive Forms work seamlessly with MVVM, providing built-in validation and straightforward interop with signal-based ViewModel state.
5. Component-Level Providers: Providing ViewModels at the component level (rather than root level) ensures proper encapsulation and makes testing easier.
6. Decentralized Architecture: Angular's DI enables a decentralized architecture where child components manage their own ViewModels, reducing parent-child coupling.
7. Framework Independence Proven: The same ViewModels (sensorViewModel, greenHouseViewModel, etc.) work identically in React, Vue, and Angular. Only the View layer changes—the business logic is completely reusable.
8. Trade-offs: Angular requires more setup (InjectionTokens, providers, ngOnInit) compared to React and Vue, but provides better DI, testability, and built-in features (Reactive Forms). The reactivity-bridging cost that used to set Angular apart—RxJS operators vs. a hand-rolled hook—has effectively disappeared now that every framework is bridging the same small signals API.
9. Standalone Components: Angular's standalone components (Angular 14+) simplify MVVM by eliminating NgModules and making components more self-contained.
10. Best Practices: Use InjectionTokens, bridge signals in ngOnInit via fromLoomSignal, reach for observe() when you need a plain local mirror, provide at component level, and keep components thin.
10.10 What's Next
We've now seen MVVM implemented in three major frameworks: React, Vue, and Angular. Each framework has its own approach to consuming ViewModel signals, but the core principle remains the same—framework-agnostic business logic with framework-specific views.
In the next chapter, we'll explore Lit Web Components, which takes a standards-based approach to MVVM. Lit uses native web component APIs and observe() to integrate with ViewModels, demonstrating that MVVM works even with vanilla web standards.
After that, we'll look at Vanilla JavaScript implementation, proving that MVVM doesn't require any framework at all—just signals and DOM manipulation.
The journey continues as we explore how MVVM adapts to different UI paradigms while maintaining the same core architecture.
Code References:
- Angular components:
apps/mvvm-angular/src/app/components/ - Signal bridge utility:
apps/mvvm-angular/src/app/utils/loom-signals.ts - Sensor list:
apps/mvvm-angular/src/app/components/sensor-list/ - Greenhouse list:
apps/mvvm-angular/src/app/components/greenhouse-list/ - Dashboard:
apps/mvvm-angular/src/app/components/dashboard/ - Shared ViewModels:
packages/view-models/src/