MVVM in Angular
How Angular's Zone.js change detection works, why the async pipe is the natural bridge to RxJS observables, and practical patterns for wiring ViewModels into Angular components — with real examples from the Web Loom Greenhouse app.
MVVM in Angular
Angular and @web-loom/mvvm-core are a natural pairing — both lean on RxJS observables as their primary reactive primitive. The integration is lighter than React or Vue because Angular already understands observables. You don't need a custom bridge hook. You do need to understand when Angular checks for changes, how to feed observables into that cycle cleanly, and how to avoid the most common pitfalls around subscriptions and memory leaks. This page covers all of it.
How Angular's Change Detection Works
Angular uses a zone-based change detection model. The key mechanism is Zone.js, a library that patches every async browser API — setTimeout, setInterval, fetch, XMLHttpRequest, Promise, event listeners — so it can intercept when async operations complete and notify Angular to check the component tree.
User event / async operation completes
↓
Zone.js intercepts
↓
Angular schedules a change detection cycle
↓
Angular walks the component tree top-down
↓
Compares current property values to previous
↓
Updates the DOM where values have changed
This is pull-based: Angular proactively checks what changed, rather than components pushing updates when data changes.
NgZone and Event Coalescing
In the Web Loom Angular app, the zone is configured with event coalescing:
// apps/mvvm-angular/src/app/app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
],
};eventCoalescing: true batches multiple DOM events that fire in the same microtask into a single change detection pass, reducing unnecessary re-checks and improving performance.
Why RxJS Fits Naturally
RxJS BehaviorSubject streams live outside Angular's zone — emitting values doesn't automatically trigger change detection. Angular solves this with the async pipe, which:
- Subscribes to an
ObservableorPromise - Calls
markForCheck()whenever a new value arrives (triggering change detection for that subtree) - Automatically unsubscribes when the component is destroyed
This makes the async pipe the cleanest, safest way to consume observables in templates — no manual subscription management, no memory leaks.
The [object Object] Pipe — Angular's Observable Bridge
The async pipe is the primary mechanism for rendering observable values in Angular templates. It handles the full subscription lifecycle for you.
Basic pattern
@Component({
standalone: true,
imports: [CommonModule],
template: `
<div *ngIf="loading$ | async">Loading…</div>
<ul *ngIf="items$ | async as items">
<li *ngFor="let item of items">{{ item.name }}</li>
</ul>
<p *ngIf="error$ | async as err" class="error">{{ err.message }}</p>
`,
})
export class ItemListComponent implements OnInit {
data$!: Observable<Item[]>;
loading$!: Observable<boolean>;
error$!: Observable<any>;
ngOnInit(): void {
this.data$ = this.vm.data$;
this.loading$ = this.vm.isLoading$;
this.error$ = this.vm.error$;
}
}The [object Object] alias syntax
*ngIf="items$ | async as items" subscribes to items$ and assigns the unwrapped value to the local template variable items. This avoids multiple subscriptions when the same stream is referenced in different parts of the template.
<ng-container *ngIf="greenhouses$ | async as greenhouseList">
<div *ngIf="greenhouseList.length > 0; else noGreenhouses">
<ul>
<li *ngFor="let gh of greenhouseList">{{ gh.name }}</li>
</ul>
</div>
</ng-container>
<ng-template #noGreenhouses>
<p>No greenhouses found.</p>
</ng-template>When you must subscribe manually
The async pipe only works in templates. When you need the latest value from an observable in a method (for example, to read the current list before a CRUD operation), you must maintain a manual subscription and store a snapshot:
private subscription: Subscription | undefined;
greenhouses: Greenhouse[] = [];
ngOnInit(): void {
this.greenhouses$ = this.vm.data$.pipe(
tap((ghs) => (this.greenhouses = ghs ?? [])),
);
this.subscription = this.greenhouses$.subscribe(); // keep snapshot in sync
}
ngOnDestroy(): void {
this.subscription?.unsubscribe();
}
handleDelete(id: string): void {
this.vm.deleteCommand.execute(id); // greenhouses snapshot was needed for guards
}Providing ViewModels via Dependency Injection
Angular's DI system is the right place to scope ViewModels. Web Loom uses InjectionToken to provide typed ViewModel instances, keeping components decoupled from the specific ViewModel implementation.
Creating an InjectionToken
import { InjectionToken } from '@angular/core';
import { greenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
export const GREENHOUSE_VIEW_MODEL = new InjectionToken<typeof greenHouseViewModel>(
'GREENHOUSE_VIEW_MODEL',
);The token is typed to the ViewModel's shape (typeof greenHouseViewModel), giving you full type safety when injecting it.
Providing and injecting
@Component({
standalone: true,
providers: [
{
provide: GREENHOUSE_VIEW_MODEL,
useValue: greenHouseViewModel, // singleton ViewModel instance
},
],
})
export class GreenhouseListComponent {
constructor(@Inject(GREENHOUSE_VIEW_MODEL) public vm: typeof greenHouseViewModel) {}
}Why not inject the ViewModel directly?
Using a token instead of a concrete class allows you to:
- Swap the ViewModel for a mock in tests without changing the component
- Provide different ViewModel instances at different DI scopes (module, component, route)
- Keep the component agnostic about where the ViewModel instance comes from
Per-component vs module-level providers
// Component-level: each instance gets the ViewModel in its providers array
@Component({
providers: [{ provide: GREENHOUSE_VIEW_MODEL, useValue: greenHouseViewModel }],
})
// Module/route-level: provide in a route config to scope to a route subtree
{
path: 'greenhouses',
component: GreenhouseListComponent,
providers: [{ provide: GREENHOUSE_VIEW_MODEL, useValue: greenHouseViewModel }],
}For the Web Loom Greenhouse app, ViewModels are module-level singletons (created once, shared across routes). Providing at the component level is correct for feature-scoped ViewModels that should be destroyed when the component is destroyed.
Lifecycle Hooks
Angular's lifecycle hooks map directly onto the MVVM lifecycle:
constructor— inject ViewModel, set upFormGroupngOnInit— assign observables, triggerfetchCommandngAfterViewInit— access DOM refs (@ViewChild), initialize chartsngOnDestroy— unsubscribe manual subscriptions
ngOnInit — start reactive state
Always assign observable properties and execute initial commands in ngOnInit, not the constructor. The constructor runs before inputs are resolved and before the component tree is established.
ngOnInit(): void {
// Assign ViewModel observables to component properties
this.data$ = this.vm.data$;
this.loading$ = this.vm.isLoading$;
this.error$ = this.vm.error$;
// Trigger initial fetch
this.vm.fetchCommand.execute();
}ngOnDestroy — clean up manual subscriptions
The async pipe manages its own subscription. Only call unsubscribe() on subscriptions you created explicitly:
ngOnDestroy(): void {
this.greenhousesSubscription?.unsubscribe();
}Modern alternative: DestroyRef + takeUntilDestroyed
Angular 16+ provides DestroyRef and the takeUntilDestroyed operator as a cleaner alternative to the ngOnDestroy + Subscription pattern:
import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({ standalone: true })
export class SensorListComponent implements OnInit {
private destroyRef = inject(DestroyRef);
ngOnInit(): void {
this.vm.data$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => (this.snapshot = data ?? []));
}
// No ngOnDestroy needed — takeUntilDestroyed handles it
}The Full Greenhouse Example
The Greenhouse app demonstrates a complete CRUD flow in an Angular standalone component.
The ViewModel (framework-agnostic)
// packages/view-models/src/GreenHouseViewModel.ts
import { createReactiveViewModel, type ViewModelFactoryConfig } from '@web-loom/mvvm-core';
import { greenHouseConfig } from '@repo/models';
import { type GreenhouseListData, GreenhouseListSchema } from '@repo/models';
const config: ViewModelFactoryConfig<GreenhouseListData, typeof GreenhouseListSchema> = {
modelConfig: greenHouseConfig,
schema: GreenhouseListSchema,
};
export const greenHouseViewModel = createReactiveViewModel(config);
export type { GreenhouseListData, GreenhouseData };The ViewModel exposes:
data$—BehaviorSubject<GreenhouseData[] | null>isLoading$—BehaviorSubject<boolean>error$—BehaviorSubject<any>fetchCommand,createCommand,updateCommand,deleteCommand— typed Commands
No Angular imports. The ViewModel works identically in React, Vue, and Angular.
The component
// apps/mvvm-angular/src/app/components/greenhouse-list/greenhouse-list.component.ts
import { Component, OnInit, OnDestroy, Inject, InjectionToken } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { GreenhouseData, greenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
import { Observable, Subscription, tap } from 'rxjs';
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, RouterLink],
templateUrl: './greenhouse-list.component.html',
providers: [{ provide: GREENHOUSE_VIEW_MODEL, useValue: greenHouseViewModel }],
})
export class GreenhouseListComponent implements OnInit, OnDestroy {
public vm: typeof greenHouseViewModel;
public greenhouses$!: Observable<GreenhouseData[] | null>;
public loading$!: Observable<boolean>;
public error$!: Observable<any>;
greenhouseForm: FormGroup;
editingGreenhouseId: string | null | undefined = null;
greenhouses: GreenhouseData[] = []; // snapshot for imperative reads
private greenhousesSubscription: Subscription | undefined;
readonly sizeOptions = ['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: [''],
});
}
ngOnInit(): void {
this.greenhouses$ = this.vm.data$.pipe(
tap((ghs) => (this.greenhouses = ghs ?? [])),
);
this.loading$ = this.vm.isLoading$;
this.error$ = this.vm.error$;
this.vm.fetchCommand.execute();
this.greenhousesSubscription = this.greenhouses$.subscribe();
}
ngOnDestroy(): void {
this.greenhousesSubscription?.unsubscribe();
}
handleSubmit(): void {
if (this.greenhouseForm.invalid) return;
const value = this.greenhouseForm.value;
if (this.editingGreenhouseId) {
const existing = this.greenhouses.find((gh) => gh.id === this.editingGreenhouseId);
if (existing) {
this.vm.updateCommand.execute({
id: this.editingGreenhouseId,
payload: { ...existing, name: value.name, location: value.location, size: value.size },
});
}
} else {
this.vm.createCommand.execute(value);
}
this.greenhouseForm.reset();
this.editingGreenhouseId = null;
}
handleUpdateForm(id?: string): void {
const gh = this.greenhouses.find((g) => g.id === id);
if (!gh) return;
this.editingGreenhouseId = gh.id;
this.greenhouseForm.patchValue({
name: gh.name,
location: gh.location,
size: gh.size,
cropType: gh.cropType ?? '',
});
}
handleDelete(id?: string): void {
if (!id) return;
this.vm.deleteCommand.execute(id);
if (this.editingGreenhouseId === id) {
this.greenhouseForm.reset();
this.editingGreenhouseId = null;
}
}
}The template
<!-- greenhouse-list.component.html -->
<section class="flex-container flex-row">
<!-- Reactive form -->
<form [formGroup]="greenhouseForm" (ngSubmit)="handleSubmit()" class="form-container">
<div class="form-group">
<label for="name">Greenhouse Name:</label>
<input id="name" formControlName="name" class="input-field" placeholder="Enter 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" rows="3" class="textarea-field"></textarea>
</div>
<div class="form-group">
<label for="size">Size:</label>
<select id="size" formControlName="size" class="select-field">
<option value="">Select size</option>
<option *ngFor="let s of sizeOptions" [value]="s">{{ s }}</option>
</select>
</div>
<button type="submit" [disabled]="greenhouseForm.invalid" class="button">
{{ editingGreenhouseId ? 'Update' : 'Create' }}
</button>
</form>
<!-- List -->
<div class="card">
<h1>Greenhouses</h1>
<div *ngIf="loading$ | async">Loading…</div>
<ng-container *ngIf="greenhouses$ | async as list">
<ul *ngIf="list.length > 0; else empty" class="list">
<li *ngFor="let gh of list" class="list-item">
<span>{{ gh.name }}</span>
<div>
<button (click)="handleDelete(gh.id)" class="button-tiny">Delete</button>
<button (click)="handleUpdateForm(gh.id)" class="button-tiny">Edit</button>
</div>
</li>
</ul>
</ng-container>
<ng-template #empty>
<p *ngIf="!(loading$ | async)">No greenhouses found.</p>
</ng-template>
</div>
</section>Reactive Forms with MVVM
Angular's ReactiveFormsModule complements the MVVM pattern well. The form state (touched, dirty, invalid) lives in FormGroup — Angular's own reactive system — while domain mutations live in ViewModel Commands. They stay separate by design.
Key bindings
[formGroup]="form"— bind the component'sFormGroupto a<form>elementformControlName="name"— bind a specific control to an input(ngSubmit)="handleSubmit()"— call a method on form submission[disabled]="form.invalid"— disable a button based on validation stateform.get('name')?.invalid— read per-field validation stateform.patchValue({...})— populate form for editing without resetting other fieldsform.reset()— clear all values and reset touched/dirty state
Where form state belongs
Keep FormGroup values in the component — not in the ViewModel. The ViewModel should receive a clean payload when a command is executed, not subscribe to FormGroup.valueChanges:
// Good — ViewModel receives final payload
handleSubmit(): void {
if (this.form.invalid) return;
this.vm.createCommand.execute(this.form.value);
this.form.reset();
}
// Avoid — ViewModel should not subscribe to form internals
ngOnInit(): void {
this.form.valueChanges.subscribe((v) => this.vm.setDraft(v)); // unnecessary coupling
}Command Binding in Templates
Commands expose observable flags directly on the instance. Bind them with the async pipe:
<!-- Loading spinner from command state -->
<button
(click)="vm.fetchCommand.execute()"
[disabled]="(vm.fetchCommand.isExecuting$ | async) === true"
>
<span *ngIf="vm.fetchCommand.isExecuting$ | async; else label">Loading…</span>
<ng-template #label>Refresh</ng-template>
</button>
<!-- Error from command -->
<p *ngIf="vm.fetchCommand.executeError$ | async as err" class="error">
Failed: {{ err.message }}
</p>Or assign command observables to component properties in ngOnInit to keep templates clean:
ngOnInit(): void {
this.isSaving$ = this.vm.createCommand.isExecuting$;
this.canSave$ = this.vm.createCommand.canExecute$;
}<button [disabled]="!(canSave$ | async)" (click)="vm.createCommand.execute(form.value)">
<ng-container *ngIf="isSaving$ | async; else saveLabel">Saving…</ng-container>
<ng-template #saveLabel>Save</ng-template>
</button>@ViewChild and AfterViewInit
When a component needs to access a DOM element after Angular has rendered the template (for example, to initialize a canvas-based chart), use @ViewChild in conjunction with the AfterViewInit lifecycle hook.
SensorReadingCardComponent example
import { Component, AfterViewInit, ElementRef, ViewChild, OnInit, Inject, InjectionToken } from '@angular/core';
import { CommonModule } from '@angular/common';
import { sensorReadingViewModel, SensorReadingListData } from '@repo/view-models/SensorReadingViewModel';
import { Observable } from 'rxjs';
import { Chart } from 'chart.js/auto';
export const SENSOR_READING_VIEW_MODEL = new InjectionToken<typeof sensorReadingViewModel>(
'SENSOR_READING_VIEW_MODEL',
);
@Component({
selector: 'app-sensor-reading-card',
standalone: true,
imports: [CommonModule],
template: `
<canvas #readingsChart></canvas>
<div *ngIf="loading$ | async">Loading readings…</div>
`,
providers: [{ provide: SENSOR_READING_VIEW_MODEL, useValue: sensorReadingViewModel }],
})
export class SensorReadingCardComponent implements OnInit, AfterViewInit {
public vm: typeof sensorReadingViewModel;
public data$!: Observable<SensorReadingListData | null>;
public loading$!: Observable<boolean>;
@ViewChild('readingsChart') readingsChartRef?: ElementRef<HTMLCanvasElement>;
private chartInstance?: Chart;
constructor(@Inject(SENSOR_READING_VIEW_MODEL) vm: typeof sensorReadingViewModel) {
this.vm = vm;
}
ngOnInit(): void {
this.data$ = this.vm.data$;
this.loading$ = this.vm.isLoading$;
this.vm.fetchCommand.execute();
}
ngAfterViewInit(): void {
// Canvas is now in the DOM — safe to initialize the chart
this.data$.subscribe((data) => {
if (data && data.length > 0 && this.readingsChartRef) {
this.initChart(data);
}
});
}
private initChart(data: SensorReadingListData): void {
const canvas = this.readingsChartRef!.nativeElement;
const ctx = canvas.getContext('2d');
if (!ctx) return;
this.chartInstance?.destroy();
this.chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: data.map((r) => new Date(r.timestamp).toLocaleTimeString()),
datasets: [{
label: 'Readings',
data: data.map((r) => r.value),
borderColor: 'rgba(75, 192, 192, 1)',
tension: 0.1,
}],
},
});
}
}Key points:
@ViewChild('readingsChart')locates the<canvas #readingsChart>elementngAfterViewInitis called after the first render — the canvas is in the DOM- The chart is destroyed and recreated on each data emission to avoid duplicate series
- This chart initialization logic belongs in the component, not the ViewModel — it's a DOM concern
Simplified Read-Only Components
When a component only needs to display data (no forms, no mutations), the setup is minimal — just assign observables in ngOnInit and let the async pipe do the rest:
// apps/mvvm-angular/src/app/layout/header/header.component.ts
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common';
import { navigationViewModel } from '@repo/shared/view-models/NavigationViewModel';
@Component({
selector: 'app-header',
standalone: true,
imports: [RouterModule, CommonModule],
template: `
<nav *ngIf="navigationList$ | async as navItems">
<a
*ngFor="let item of navItems"
[routerLink]="item.path"
routerLinkActive="active"
>{{ item.label }}</a>
</nav>
`,
})
export class HeaderComponent {
public navigationList$ = navigationViewModel.navigationList.items$;
}No ngOnInit, no ngOnDestroy, no subscriptions. The async pipe handles everything.
ChangeDetectionStrategy.OnPush
By default, Angular checks every component in the tree on every change detection pass. ChangeDetectionStrategy.OnPush narrows this: Angular only re-checks a component when:
- An
@Input()reference changes - An event fires from inside the component
- An
Observablepiped throughasyncemits a new value markForCheck()is called manually
Because all ViewModel state is consumed through the async pipe, switching to OnPush works seamlessly with the MVVM pattern:
import { ChangeDetectionStrategy } from '@angular/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
// ... rest of decorator
})
export class GreenhouseListComponent implements OnInit, OnDestroy {
// No changes required — async pipe triggers markForCheck() automatically
}OnPush is the recommended strategy for components consuming ViewModel observables. It avoids unnecessary checks and makes the component's update triggers explicit.
Angular Signals with Web Loom ViewModels
Angular Signals (introduced in Angular 16, stable and recommended in Angular 19) are a synchronous, push-based reactive primitive built directly into the framework. They complement Web Loom's architecture particularly well because:
- The ViewModel stays completely unchanged — it still exposes
BehaviorSubjectstreams - The
@angular/core/rxjs-interoppackage providestoSignal(), a one-line bridge from any Observable to a Signal - Signal-based components can drop Zone.js entirely, reducing bundle size and improving performance
- The new
@if/@fortemplate control flow (Angular 17+) reads signals without pipes
BehaviorSubject (ViewModel)
↓
toSignal() ← one-time bridge in the component
↓
Angular Signal ← component reads it like a plain function call
↓
Template @if / @for ← no async pipe, no structural directive noise
toSignal() — the Observable-to-Signal bridge
toSignal() from @angular/core/rxjs-interop subscribes to an Observable and returns a read-only Signal that always holds the latest emitted value. It automatically unsubscribes when the component is destroyed (tied to the injection context).
import { toSignal } from '@angular/core/rxjs-interop';
@Component({ standalone: true })
export class GreenhouseListComponent implements OnInit {
private vm = inject_vm_here; // via @Inject or inject()
// Convert ViewModel observables to signals — one line each
readonly greenhouses = toSignal(this.vm.data$, { initialValue: null });
readonly loading = toSignal(this.vm.isLoading$, { initialValue: false });
readonly error = toSignal(this.vm.error$, { initialValue: null });
}The initialValue option sets the signal's value before the first emission — important when the observable is a cold one. For BehaviorSubject, you can omit it and Angular will use the current value synchronously:
// BehaviorSubject emits synchronously — no initialValue needed
readonly greenhouses = toSignal(this.vm.data$);
readonly loading = toSignal(this.vm.isLoading$);Signal-based template control flow
With signals, the template uses Angular's new built-in control flow blocks (@if, @for, @switch) instead of *ngIf / *ngFor structural directives. No async pipe — signals are read by calling them like functions:
<!-- Signals-based template — no async pipe, no CommonModule needed -->
@if (loading()) {
<div>Loading…</div>
}
@if (greenhouses(); as list) {
@if (list && list.length > 0) {
<ul>
@for (gh of list; track gh.id) {
<li>
<span>{{ gh.name }}</span>
<button (click)="handleDelete(gh.id)">Delete</button>
<button (click)="handleUpdateForm(gh.id)">Edit</button>
</li>
}
</ul>
} @else {
<p>No greenhouses found.</p>
}
}
@if (error(); as err) {
<p class="error">{{ err.message }}</p>
}Because greenhouses() is a plain function call, Angular's template compiler knows exactly which signals a template reads. When a signal changes, only the components that read it are re-rendered — no zone patching required.
computed() — derived signal state
Use Angular's computed() to derive display state from ViewModel signals without extra subscriptions. Computed signals are lazy (only evaluated when read) and memoized (only re-evaluated when a dependency changes):
import { computed } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({ standalone: true })
export class GreenhouseListComponent {
readonly greenhouses = toSignal(this.vm.data$, { initialValue: [] as GreenhouseData[] });
readonly loading = toSignal(this.vm.isLoading$, { initialValue: false });
// Derived from the greenhouses signal — no extra subscription
readonly count = computed(() => this.greenhouses()?.length ?? 0);
readonly hasData = computed(() => (this.greenhouses()?.length ?? 0) > 0);
readonly isIdle = computed(() => !this.loading() && this.hasData());
readonly sortedByName = computed(() =>
[...(this.greenhouses() ?? [])].sort((a, b) => a.name.localeCompare(b.name)),
);
}In the template:
<h2>Greenhouses ({{ count() }})</h2>
@for (gh of sortedByName(); track gh.id) {
<li>{{ gh.name }}</li>
}effect() — side effects without subscriptions
effect() runs a function whenever any signal it reads changes. Use it for imperative side effects (logging, chart updates, notifications) that would previously require a manual .subscribe():
import { effect } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({ standalone: true })
export class SensorReadingCardComponent implements AfterViewInit {
@ViewChild('readingsChart') chartRef?: ElementRef<HTMLCanvasElement>;
readonly readings = toSignal(this.vm.data$, { initialValue: null });
constructor() {
// effect() runs whenever readings() changes — replaces subscribe in ngAfterViewInit
effect(() => {
const data = this.readings();
if (data && this.chartRef) {
this.initChart(data);
}
});
}
private initChart(data: SensorReadingListData): void {
// ... Chart.js initialization
}
}effect() must be called in an injection context (constructor or field initializer). It cleans up automatically when the component is destroyed.
Full Greenhouse component — signals version
Here is the complete Greenhouse CRUD component rewritten to use Signals and the new template control flow. The ViewModel is untouched.
import {
Component, OnInit, OnDestroy, Inject, InjectionToken,
computed, effect, signal,
} from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { toSignal } from '@angular/core/rxjs-interop';
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,
// CommonModule not needed — @if / @for are built-in, no async pipe required
imports: [ReactiveFormsModule, RouterLink],
providers: [{ provide: GREENHOUSE_VIEW_MODEL, useValue: greenHouseViewModel }],
template: `
<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 id="name" formControlName="name" class="input-field" />
@if (nameInvalid()) {
<div class="error-message">Name is required.</div>
}
</div>
<div class="form-group">
<label for="location">Location:</label>
<textarea id="location" formControlName="location" rows="3"></textarea>
</div>
<div class="form-group">
<label for="size">Size:</label>
<select id="size" formControlName="size">
<option value="">Select size</option>
@for (s of sizeOptions; track s) {
<option [value]="s">{{ s }}</option>
}
</select>
</div>
<button type="submit" [disabled]="greenhouseForm.invalid">
{{ editingId() ? 'Update' : 'Create' }}
</button>
</form>
<div class="card">
<h1>Greenhouses ({{ count() }})</h1>
@if (loading()) {
<p>Loading…</p>
}
@if (hasData()) {
<ul class="list">
@for (gh of greenhouses()!; track gh.id) {
<li class="list-item">
<span>{{ gh.name }}</span>
<div>
<button (click)="handleDelete(gh.id)" class="button-tiny">Delete</button>
<button (click)="handleUpdateForm(gh.id)" class="button-tiny">Edit</button>
</div>
</li>
}
</ul>
} @else if (!loading()) {
<p>No greenhouses found.</p>
}
@if (error(); as err) {
<p class="error">{{ err.message }}</p>
}
</div>
</section>
`,
})
export class GreenhouseListSignalsComponent implements OnInit {
public vm: typeof greenHouseViewModel;
// ViewModel observables → Angular Signals (one line each)
readonly greenhouses = toSignal(this.vm.data$, { initialValue: null });
readonly loading = toSignal(this.vm.isLoading$, { initialValue: false });
readonly error = toSignal(this.vm.error$, { initialValue: null });
// Derived display state
readonly count = computed(() => this.greenhouses()?.length ?? 0);
readonly hasData = computed(() => (this.greenhouses()?.length ?? 0) > 0);
// Local component UI state as signals
readonly editingId = signal<string | null>(null);
// Reactive form
greenhouseForm: FormGroup;
readonly sizeOptions = ['25sqm', '50sqm', '100sqm'] as const;
// Derived form validation signal
readonly nameInvalid = computed(() => {
const ctrl = this.greenhouseForm.get('name');
return ctrl?.invalid && (ctrl?.dirty || ctrl?.touched);
});
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: [''],
});
}
ngOnInit(): void {
this.vm.fetchCommand.execute();
}
handleSubmit(): void {
if (this.greenhouseForm.invalid) return;
const value = this.greenhouseForm.value;
const id = this.editingId();
if (id) {
const existing = this.greenhouses()?.find((gh) => gh.id === id);
if (existing) {
this.vm.updateCommand.execute({ id, payload: { ...existing, ...value } });
}
} else {
this.vm.createCommand.execute(value);
}
this.greenhouseForm.reset();
this.editingId.set(null);
}
handleUpdateForm(id?: string): void {
const gh = this.greenhouses()?.find((g) => g.id === id);
if (!gh) return;
this.editingId.set(gh.id ?? null);
this.greenhouseForm.patchValue({
name: gh.name, location: gh.location,
size: gh.size, cropType: gh.cropType ?? '',
});
}
handleDelete(id?: string): void {
if (!id) return;
this.vm.deleteCommand.execute(id);
if (this.editingId() === id) {
this.greenhouseForm.reset();
this.editingId.set(null);
}
}
}What changed compared to the classic version:
import CommonModule→ not needed —@if/@forare built-ingreenhouses$ | async as list→greenhouses()— plain function call*ngIf/*ngForstructural directives →@if/@forbuilt-in blockseditingGreenhouseId: string | null→editingId = signal<string | null>(null)- Manual
Subscription+ngOnDestroy→toSignal()manages lifecycle automatically - Snapshot array for imperative reads → read
greenhouses()directly in handlers
The ViewModel (greenHouseViewModel) is identical in both versions.
Zoneless Angular (Angular 19+)
Signals enable zoneless change detection — Angular updates the DOM only when a signal value changes, rather than after every async operation. To opt in, replace provideZoneChangeDetection in app.config.ts:
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideExperimentalZonelessChangeDetection(), // replaces provideZoneChangeDetection
provideRouter(routes),
],
};And remove zone.js from angular.json polyfills:
"polyfills": []With zoneless enabled, Zone.js is gone from the bundle. Angular only re-renders when a Signal (or async pipe) notifies it. For ViewModel-based components using toSignal(), this works with zero further changes.
provideExperimentalZonelessChangeDetectionis available in Angular 18+ and stable for production use in Angular 19+. TheExperimentalprefix is a naming artifact and does not indicate instability.
toObservable() — going the other direction
If you need to pass an Angular Signal back into a ViewModel (for example, a route param or an @Input() that the ViewModel should react to), use toObservable():
import { input } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs';
@Component({ standalone: true })
export class SensorDetailComponent implements OnInit {
// Angular 17+ signal input
readonly sensorId = input.required<string>();
// Bridge signal → Observable → ViewModel
private readonly sensorId$ = toObservable(this.sensorId);
ngOnInit(): void {
// Re-fetch whenever the route param changes
this.sensorId$.pipe(
switchMap((id) => {
this.vm.setFilter(id);
return this.vm.fetchCommand.execute();
}),
).subscribe();
}
}When to use Signals vs the async pipe
- New component in Angular 17+ — use Signals +
toSignal()+@if/@for - Existing component with
*ngIf/asyncpipe — leave it; both approaches work side by side - Zoneless app target — Signals are required for change detection;
asyncpipe still works as a fallback - Complex RxJS pipeline (debounce, switchMap, retry) — keep as Observable; bridge to a Signal only at the template edge
- Local UI state (editing ID, expanded row, active tab) — use
signal(), it's simpler than aBehaviorSubject - Side effects on data change (chart updates, notifications) — prefer
effect()over a manual.subscribe()
The key rule: the bridge lives in the component, not the ViewModel. Call toSignal() in the component when you want Signals syntax. The ViewModel exposes BehaviorSubject streams regardless of which Angular style the consumer uses.
Testing Angular Components with ViewModels
Because the ViewModel is provided via InjectionToken, tests can swap it for a mock without touching the component logic.
Testing with a mock ViewModel
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { BehaviorSubject } from 'rxjs';
import { GreenhouseListComponent, GREENHOUSE_VIEW_MODEL } from './greenhouse-list.component';
describe('GreenhouseListComponent', () => {
let fixture: ComponentFixture<GreenhouseListComponent>;
let component: GreenhouseListComponent;
const data$ = new BehaviorSubject<any[]>([]);
const isLoading$ = new BehaviorSubject(false);
const error$ = new BehaviorSubject<any>(null);
const mockFetchCommand = { execute: jasmine.createSpy('execute'), isExecuting$: isLoading$ };
const mockCreateCommand = { execute: jasmine.createSpy('execute'), isExecuting$: new BehaviorSubject(false) };
const mockDeleteCommand = { execute: jasmine.createSpy('execute') };
const mockVm = { data$, isLoading$, error$, fetchCommand: mockFetchCommand, createCommand: mockCreateCommand, deleteCommand: mockDeleteCommand };
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GreenhouseListComponent, CommonModule, ReactiveFormsModule],
providers: [
{ provide: GREENHOUSE_VIEW_MODEL, useValue: mockVm },
],
}).compileComponents();
fixture = TestBed.createComponent(GreenhouseListComponent);
component = fixture.componentInstance;
fixture.detectChanges(); // triggers ngOnInit
});
it('calls fetchCommand on init', () => {
expect(mockFetchCommand.execute).toHaveBeenCalledOnce();
});
it('renders greenhouse names', async () => {
data$.next([{ id: '1', name: 'Alpine House', location: 'Zone A' }]);
fixture.detectChanges();
await fixture.whenStable();
const items = fixture.nativeElement.querySelectorAll('.list-item');
expect(items.length).toBe(1);
expect(items[0].textContent).toContain('Alpine House');
});
it('disables submit when form is invalid', () => {
const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBeTrue();
});
it('calls createCommand when form is submitted with valid data', () => {
component.greenhouseForm.patchValue({
name: 'Tropical House',
location: 'Zone B',
size: '50sqm',
});
fixture.detectChanges();
component.handleSubmit();
expect(mockCreateCommand.execute).toHaveBeenCalledWith(
jasmine.objectContaining({ name: 'Tropical House' }),
);
});
});Testing the ViewModel independently
Because ViewModels have no Angular imports, they can be tested with plain Vitest — no TestBed, no fixtures:
import { describe, it, expect, beforeEach } from 'vitest';
import { firstValueFrom } from 'rxjs';
import { greenHouseViewModel } from './GreenHouseViewModel';
describe('GreenHouseViewModel', () => {
it('starts with null data and not loading', async () => {
const data = await firstValueFrom(greenHouseViewModel.data$);
const loading = await firstValueFrom(greenHouseViewModel.isLoading$);
expect(data).toBeNull();
expect(loading).toBe(false);
});
it('sets isLoading$ to true while fetch is in progress', () => {
const states: boolean[] = [];
const sub = greenHouseViewModel.isLoading$.subscribe((v) => states.push(v));
greenHouseViewModel.fetchCommand.execute();
expect(states).toContain(true);
sub.unsubscribe();
});
});Dos and Don'ts
Do assign ViewModel observables in ngOnInit, not the constructor.
// Good
ngOnInit(): void {
this.data$ = this.vm.data$;
this.vm.fetchCommand.execute();
}
// Avoid — inputs aren't resolved yet in the constructor
constructor(@Inject(VM_TOKEN) private vm: GreenhouseVM) {
this.data$ = this.vm.data$; // could work, but ngOnInit is the right place
}Do use the async pipe for all observable rendering. It unsubscribes automatically.
<!-- Good -->
<ul *ngIf="items$ | async as items">
<li *ngFor="let i of items">{{ i.name }}</li>
</ul>
<!-- Avoid — manual subscription in the component for display-only data -->
ngOnInit(): void {
this.sub = this.vm.data$.subscribe((d) => (this.items = d));
}Do use InjectionToken to provide ViewModels so tests can substitute a mock.
// Good — swappable via DI
export const VM_TOKEN = new InjectionToken<GreenhouseVM>('GreenhouseVM');
providers: [{ provide: VM_TOKEN, useValue: greenHouseViewModel }]
// Avoid — hard-wired, impossible to swap in tests
constructor(private vm: GreenHouseViewModelClass) {}Do unsubscribe from every manual subscription in ngOnDestroy.
// Good
ngOnDestroy(): void {
this.subscription?.unsubscribe();
}
// Better (Angular 16+) — no ngOnDestroy needed
private destroyRef = inject(DestroyRef);
this.vm.data$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(...);Do use ChangeDetectionStrategy.OnPush for components that consume ViewModel observables via the async pipe.
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
// All updates arrive through async pipe — OnPush is safe
})Don't import Angular modules inside a ViewModel.
// Wrong — ViewModels are framework-agnostic
import { Injectable } from '@angular/core'; // ← breaks portability
// Correct — plain TypeScript class
export class GreenhouseViewModel extends RestfulApiViewModel<...> { ... }Don't subscribe to formGroup.valueChanges in the ViewModel. Let the component call a command with the final value on submit.
// Wrong — couples the ViewModel to form lifecycle
ngOnInit(): void {
this.form.valueChanges.subscribe((v) => this.vm.setDraft(v));
}
// Correct — ViewModel receives clean payload on submit
handleSubmit(): void {
if (this.form.invalid) return;
this.vm.createCommand.execute(this.form.value);
}Don't put DOM logic (chart initialization, focus management, scroll) in the ViewModel.
// Wrong — ViewModel should not touch the DOM
class SensorViewModel {
initChart(canvas: HTMLCanvasElement) { ... }
}
// Correct — DOM work belongs in the component's AfterViewInit hook
ngAfterViewInit(): void {
this.data$.subscribe((data) => this.initChart(data));
}Where to Go Next
- ViewModels — the full API for Commands, RestfulApiViewModel, and lifecycle
- Models — how Models fetch, cache, and own reactive data
- MVVM in React — the React integration with
useObservableanduseSyncExternalStore - MVVM in Vue — the Vue 3 integration with composables and provide/inject