Web Loom logo
MVVM CoreMVVM in Angular

MVVM in Angular

How Angular's change detection works, why fromLoomSignal is the natural bridge for @web-loom/signals-core signals, and practical patterns for wiring ViewModels into Angular components — with real examples from the Web Loom Greenhouse app.

MVVM in Angular

@web-loom/mvvm-core ViewModels expose @web-loom/signals-core signals — data$, isLoading$, error$, and Command state are all ReadonlySignal<T>. Angular has had its own native Signals system since v16 (stable and recommended since v19), so the integration is a small, one-line-per-property bridge rather than a subscription-management exercise. This page covers Angular's change detection model, the fromLoomSignal bridge Web Loom uses throughout its Angular app, and the surrounding component patterns (DI, forms, @ViewChild, testing).


How Angular's Change Detection Works

Angular uses a zone-based change detection model by default. 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 Angular Signals Fit Naturally

@web-loom/signals-core signals — like Angular's own Signals — live outside Zone.js and don't automatically trigger change detection just by being read or written to from ViewModel code. Angular's Signals system solves this natively: any Signal a template reads (by calling it, mySignal()) is tracked directly by Angular's reactivity graph, independent of Zone.js. Since data$/isLoading$/error$ are already signal-shaped (.get()/.peek()/.subscribe()), converting them into genuine Angular Signals is a one-line bridge — no async pipe, no manual Subscription bookkeeping required for the common case.


The fromLoomSignal Bridge

fromLoomSignal mirrors a Web Loom signal into a native Angular signal, torn down automatically via DestroyRef:

// 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';
 
/**
 * Bridge a Web Loom signal (from @web-loom/signals-core) to a native Angular
 * signal. Reads the current value synchronously and mirrors every change;
 * the subscription is torn down with the injector's DestroyRef.
 *
 * Must be called in an injection context (constructor / field initializer)
 * unless a DestroyRef is passed explicitly.
 */
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();
}

How it works:

  • signal(source.peek()) seeds a native Angular signal with the Web Loom signal's current value.
  • source.subscribe(...) mirrors every future change into the Angular signal. No observe() needed here since signal() is already seeded from .peek().
  • (destroyRef ?? inject(DestroyRef)).onDestroy(unsubscribe) ties the teardown to Angular's own destroy lifecycle — the caller doesn't need ngOnDestroy at all for this.
  • The return type is Signal<T> (Angular's read-only signal type, via .asReadonly()), not ReadonlySignal<T> (the Web Loom type) — from this point on you're working entirely in Angular's own Signals API.

Usage is one line per signal, typically inside ngOnInit (constructor runs before Angular has resolved @Input()s and injected dependencies fully, so ngOnInit is the conventional place):

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();
}

Read them in the template by calling them as functions — loading$(), not loading$ or loading$ | async:

<div *ngIf="loading$()">Loading…</div>
<ng-container *ngIf="greenhouses$() as list">
  <li *ngFor="let gh of list">{{ gh.name }}</li>
</ng-container>

Angular's structural directives (*ngIf, *ngFor) work fine with signal function calls — you don't need the newer @if/@for control-flow syntax to benefit from signals, though it pairs well if you're already using Angular 17+.


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 up FormGroup
  • ngOnInit — bridge signals with fromLoomSignal, trigger fetchCommand
  • ngAfterViewInit — access DOM refs (@ViewChild), initialize charts
  • ngOnDestroy — rarely needed; fromLoomSignal and DestroyRef.onDestroy handle teardown for you

ngOnInit — start reactive state

Always bridge signals 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 {
  // Bridge ViewModel signals to 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);
 
  // Trigger initial fetch
  this.vm.fetchCommand.execute();
}

Manual subscriptions — use observe() + DestroyRef.onDestroy

fromLoomSignal handles the common case (mirror a signal into the template). For an imperative snapshot — reading the current list inside an event handler, for example — subscribe manually with observe() and register the teardown on DestroyRef instead of writing a ngOnDestroy:

private destroyRef = inject(DestroyRef);
greenhouses: GreenhouseData[] = []; // imperative snapshot
 
ngOnInit(): void {
  this.vm.fetchCommand.execute();
  this.destroyRef.onDestroy(
    observe(this.vm.data$, (ghs) => (this.greenhouses = ghs ?? [])),
  );
}

observe(sig, fn) — from @web-loom/signals-core — calls fn immediately with the current value, then on every change, giving you a synchronously up-to-date snapshot without a separate ngOnDestroy method.


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$ReadonlySignal<GreenhouseData[] | null>
  • isLoading$ReadonlySignal<boolean>
  • error$ReadonlySignal<any>
  • fetchCommand, createCommand, updateCommand, deleteCommand — typed Commands

No Angular imports. The ViewModel works identically in React, Vue, and Angular.

The component

From 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, RouterLink],
  templateUrl: './greenhouse-list.component.html',
  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[] = []; // snapshot for imperative reads
  private destroyRef = inject(DestroyRef);
 
  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$ = 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) 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: loading$()/greenhouses$() are Angular Signals — call them as functions -->
  <div class="card">
    <h1>Greenhouses</h1>
 
    <div *ngIf="loading$()">Loading…</div>
 
    <ng-container *ngIf="greenhouses$() 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$()">No greenhouses found.</p>
    </ng-template>
  </div>
 
</section>

No async pipe anywhere — loading$() and greenhouses$() are plain Angular signal reads.


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's FormGroup to a <form> element
  • formControlName="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 state
  • form.get('name')?.invalid — read per-field validation state
  • form.patchValue({...}) — populate form for editing without resetting other fields
  • form.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 signal flags directly on the instance. Bridge them with fromLoomSignal and read them by calling the signal:

ngOnInit(): void {
  this.isSaving$ = fromLoomSignal(this.vm.createCommand.isExecuting$, this.destroyRef);
  this.canSave$  = fromLoomSignal(this.vm.createCommand.canExecute$, this.destroyRef);
  this.saveError$ = fromLoomSignal(this.vm.createCommand.executeError$, this.destroyRef);
}
<!-- Loading spinner and disabled state from command signals -->
<button
  (click)="vm.createCommand.execute(form.value)"
  [disabled]="!canSave$()"
>
  {{ isSaving$() ? 'Saving…' : 'Save' }}
</button>
 
<!-- Error from command -->
<p *ngIf="saveError$() as err" class="error">
  Failed: {{ err.message }}
</p>

@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

From apps/mvvm-angular/src/app/components/sensor-reading-card/sensor-reading-card.component.ts:

import { Component, AfterViewInit, ElementRef, ViewChild, 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 { sensorReadingViewModel, SensorReadingListData } from '@repo/view-models/SensorReadingViewModel';
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$()">Loading readings…</div>
  `,
  providers: [{ provide: SENSOR_READING_VIEW_MODEL, useValue: sensorReadingViewModel }],
})
export class SensorReadingCardComponent implements OnInit, AfterViewInit {
  public vm: typeof sensorReadingViewModel;
  public data$!: Signal<SensorReadingListData | null>;
  public loading$!: Signal<boolean>;
 
  @ViewChild('readingsChart') readingsChartRef?: ElementRef<HTMLCanvasElement>;
  private chartInstance?: Chart;
  private destroyRef = inject(DestroyRef);
 
  constructor(@Inject(SENSOR_READING_VIEW_MODEL) vm: typeof sensorReadingViewModel) {
    this.vm = vm;
  }
 
  ngOnInit(): void {
    this.data$ = fromLoomSignal(this.vm.data$, this.destroyRef);
    this.loading$ = fromLoomSignal(this.vm.isLoading$, this.destroyRef);
    this.vm.fetchCommand.execute();
  }
 
  ngAfterViewInit(): void {
    // Canvas is now in the DOM — safe to initialize the chart.
    // observe() delivers the current value immediately, then on every change.
    this.destroyRef.onDestroy(
      observe(this.vm.data$, (data) => {
        if (data && data.length > 0 && this.readingsChartRef) {
          this.initChart(data);
        }
      }),
    );
  }
 
  private initChart(data: SensorReadingListData): void {
    if (!this.readingsChartRef) return;
    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> element
  • ngAfterViewInit is called after the first render — the canvas is in the DOM
  • observe() (not fromLoomSignal) is used here because chart re-initialization is an imperative DOM side effect, not template-bound state — there's no Angular signal to read in a template for this
  • The chart is destroyed and recreated on each data change 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 — bridge one signal in a field initializer and let the template call it:

// apps/mvvm-angular/src/app/layout/header/header.component.ts
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { fromLoomSignal } from '../../utils/loom-signals';
import { RouterModule } from '@angular/router';
import { navigationViewModel } from '@repo/shared/view-models/NavigationViewModel';
 
@Component({
  selector: 'app-header',
  imports: [RouterModule, CommonModule],
  templateUrl: './header.component.html',
})
export class HeaderComponent {
  // Field initializer runs in an injection context — no explicit DestroyRef needed
  public navigationList$ = fromLoomSignal(navigationViewModel.navigationList.items$);
}
<nav *ngIf="navigationList$() as navItems">
  <a *ngFor="let item of navItems" [routerLink]="item.path" routerLinkActive="active">{{ item.label }}</a>
</nav>

No ngOnInit, no ngOnDestroy, no manual subscriptions. fromLoomSignal handles everything, including teardown via the injected DestroyRef it resolves internally when none is passed explicitly.


computed() and effect() for Derived State

Once ViewModel state is bridged into Angular Signals with fromLoomSignal, Angular's own computed() and effect() compose over it exactly like any other Angular signal — no extra subscriptions needed.

import { computed, effect } from '@angular/core';
 
export class GreenhouseListComponent implements OnInit {
  greenhouses$!: Signal<GreenhouseData[] | null>;
  loading$!: Signal<boolean>;
 
  // Derived signals — lazy and memoized, re-evaluated only when a dependency changes
  readonly count   = computed(() => this.greenhouses$()?.length ?? 0);
  readonly hasData = computed(() => (this.greenhouses$()?.length ?? 0) > 0);
 
  ngOnInit(): void {
    this.greenhouses$ = fromLoomSignal(this.vm.data$, this.destroyRef);
    this.loading$ = fromLoomSignal(this.vm.isLoading$, this.destroyRef);
 
    // effect() runs whenever a signal it reads changes — replaces a manual .subscribe()
    // for imperative side effects. Must be called in an injection context.
    effect(() => {
      console.log(`${this.count()} greenhouses loaded`);
    });
  }
}

effect() cleans up automatically when the component is destroyed, the same way fromLoomSignal's internal subscription does.


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:

  1. An @Input() reference changes
  2. An event fires from inside the component
  3. A Signal read in the template changes
  4. markForCheck() is called manually

Because all ViewModel state is consumed through Angular Signals (via fromLoomSignal), 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 {
  // No changes required — signal reads notify Angular automatically
}

OnPush is the recommended strategy for components consuming ViewModel signals. It avoids unnecessary checks and makes the component's update triggers explicit.


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. Since fromLoomSignal-bridged state is already signal-based, it works unchanged in a zoneless app. 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 notifies it. For ViewModel-based components using fromLoomSignal, this works with zero further changes.

provideExperimentalZonelessChangeDetection is available in Angular 18+ and stable for production use in Angular 19+. The Experimental prefix is a naming artifact and does not indicate instability.


RxJS Interop (only if you need it)

@web-loom/mvvm-core doesn't use RxJS at all, so most Angular apps built on it never need @angular/core/rxjs-interop or @web-loom/signals-core/rxjs. Reach for interop only when a different part of the app is genuinely RxJS-based — an existing NgRx store, a complex debounce/retry pipeline, a websocket library that returns Observables — and you need to connect that to a ViewModel or an Angular Signal.

Angular Signal ↔ RxJS Observable (@angular/core/rxjs-interop, built into Angular):

import { toSignal, toObservable } from '@angular/core/rxjs-interop';
 
// Observable -> Signal
const searchResults = toSignal(searchResults$, { initialValue: [] });
 
// Signal -> Observable (e.g. to feed a signal input into an RxJS pipeline)
const sensorId$ = toObservable(this.sensorId); // this.sensorId is a Signal input

Web Loom signal ↔ RxJS Observable (@web-loom/signals-core/rxjs, optional peer dependency — see Signals Core: RxJS Interop):

import { toObservable, fromObservable } from '@web-loom/signals-core/rxjs';
 
const data$ = toObservable(this.vm.data$); // Web Loom signal -> Observable

For ViewModel state itself, fromLoomSignal remains the right tool — it bridges directly from @web-loom/signals-core to Angular's native Signals with no RxJS in between.


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 { signal } from '@web-loom/signals-core';
import { GreenhouseListComponent, GREENHOUSE_VIEW_MODEL } from './greenhouse-list.component';
 
describe('GreenhouseListComponent', () => {
  let fixture: ComponentFixture<GreenhouseListComponent>;
  let component: GreenhouseListComponent;
 
  const data$ = signal<any[]>([]);
  const isLoading$ = signal(false);
  const error$ = signal<any>(null);
 
  const mockFetchCommand = { execute: jasmine.createSpy('execute'), isExecuting$: isLoading$ };
  const mockCreateCommand = { execute: jasmine.createSpy('execute'), isExecuting$: signal(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$.set([{ 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 } from 'vitest';
import { greenHouseViewModel } from './GreenHouseViewModel';
 
describe('GreenHouseViewModel', () => {
  it('starts with null data and not loading', () => {
    expect(greenHouseViewModel.data$.peek()).toBeNull();
    expect(greenHouseViewModel.isLoading$.peek()).toBe(false);
  });
 
  it('sets isLoading$ to true while fetch is in progress', () => {
    const states: boolean[] = [];
    const unsubscribe = greenHouseViewModel.isLoading$.subscribe((v) => states.push(v));
 
    greenHouseViewModel.fetchCommand.execute();
    expect(states).toContain(true);
 
    unsubscribe();
  });
});

Dos and Don'ts

Do bridge ViewModel signals with fromLoomSignal in ngOnInit, not the constructor.

// Good
ngOnInit(): void {
  this.data$ = fromLoomSignal(this.vm.data$, this.destroyRef);
  this.vm.fetchCommand.execute();
}
 
// Avoid — inputs aren't resolved yet in the constructor
constructor(@Inject(VM_TOKEN) private vm: GreenhouseVM) {
  this.data$ = fromLoomSignal(this.vm.data$); // could work, but ngOnInit is the right place
}

Do call bridged signals as functions in templates. No async pipe is needed for ViewModel state.

<!-- Good -->
<ul *ngIf="items$() 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 pass DestroyRef explicitly to fromLoomSignal when bridging inside ngOnInit (rather than a field initializer), since ngOnInit is not itself an injection context.

// Good
private destroyRef = inject(DestroyRef);
ngOnInit(): void {
  this.data$ = fromLoomSignal(this.vm.data$, this.destroyRef);
}
 
// Also fine — field initializers run in an injection context, so no explicit destroyRef needed
readonly data$ = fromLoomSignal(this.vm.data$);

Do use ChangeDetectionStrategy.OnPush for components that consume ViewModel state via bridged signals.

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  // All updates arrive through bridged signals — 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.destroyRef.onDestroy(observe(this.vm.data$, (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
  • Signals Core — the signal/computed/effect primitives underlying mvvm-core, plus the RxJS interop subpath
  • MVVM in React — the React integration with useSignal and useSyncExternalStore
  • MVVM in Vue — the Vue 3 integration with composables and provide/inject
Was this helpful?
Web Loom logo
Copyright © Web Loom. All rights reserved.