Chapter 6: The View Layer Contract
In the previous chapter, we explored ViewModels—the presentation logic layer that transforms domain data into view-ready state. But ViewModels don't exist in isolation. They need Views to consume their signals and render UI. This chapter examines the View layer: its responsibilities, its relationship with ViewModels, and how to implement it across different frameworks while maintaining the same business logic.
The View layer is where MVVM's framework independence becomes tangible. The same ViewModel—with identical business logic, validation, and state management—can power React components, Vue templates, Angular views, Lit web components, and even vanilla JavaScript. The View layer is the only part that changes between frameworks, and it should be as thin as possible.
The Dumb View Philosophy
A well-designed View in MVVM is "dumb"—it contains minimal logic and delegates everything to the ViewModel. This isn't a criticism; it's a design principle that keeps Views simple, testable, and replaceable.
What a View should do:
- Read ViewModel signals
- Render data from those signals
- Capture user interactions and call ViewModel commands
- Handle framework-specific concerns (routing, animations, accessibility)
What a View should NOT do:
- Contain business logic or validation rules
- Make API calls or access repositories directly
- Transform or compute data (that's the ViewModel's job)
- Maintain state beyond what's needed for UI interactions (form inputs, modal visibility)
This separation creates a clear contract: the ViewModel exposes signals and commands; the View reads and invokes.
Let's see this contract in action with our GreenWatch greenhouse monitoring system. We'll use the same SensorViewModel across three different frameworks to demonstrate how the View layer adapts while the business logic remains unchanged.
The ViewModel: Our Single Source of Truth
Before we look at Views, let's examine the ViewModel they'll consume. Here's the actual SensorViewModel from our monorepo:
// packages/view-models/src/SensorViewModel.ts
import { RestfulApiViewModel } from '@web-loom/mvvm-core';
import { SensorListSchema, type SensorListData, SensorModel } from '@repo/models';
export class SensorViewModel extends RestfulApiViewModel<
SensorListData,
typeof SensorListSchema
> {
constructor(model: SensorModel) {
super(model);
}
}
// Singleton instance shared across all Views
const sensorModel = new SensorModel();
export const sensorViewModel = new SensorViewModel(sensorModel);
export type { SensorListData };This ViewModel extends RestfulApiViewModel, which provides:
data$: ReadonlySignal<SensorListData | null>- The sensor list dataisLoading$: ReadonlySignal<boolean>- Loading stateerror$: ReadonlySignal<any>- Error statefetchCommand.execute()- Command to fetch sensors from the API
Notice what's NOT here: no React hooks, no Vue refs, no Angular decorators. This ViewModel is pure TypeScript with @web-loom/signals-core signals. It works in any JavaScript environment.
Now let's see how different frameworks consume this ViewModel.
React Implementation: Hooks and Subscriptions
React Views use hooks to read ViewModel signals. The pattern is straightforward: create a custom useSignal hook backed by useSyncExternalStore.
The useSignal Hook
// apps/mvvm-react/src/hooks/useSignal.ts
import { useSyncExternalStore } from 'react';
import type { ReadonlySignal } from '@web-loom/signals-core';
export function useSignal<T>(sig: ReadonlySignal<T>): T {
return useSyncExternalStore(sig.subscribe, sig.get, sig.get);
}This hook:
- Reads the current value synchronously via
sig.get— no initial-value parameter, no first-render flash - Subscribes to the signal via
sig.subscribe, whichuseSyncExternalStoremanages internally - Re-renders the component on every change
- Cleans up the subscription automatically when the component unmounts
React Dashboard Component
Here's how the React Dashboard uses this hook to consume multiple ViewModels:
// apps/mvvm-react/src/components/Dashboard.tsx
import React, { useEffect } from 'react';
import { greenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
import { sensorViewModel } from '@repo/view-models/SensorViewModel';
import { sensorReadingViewModel } from '@repo/view-models/SensorReadingViewModel';
import { thresholdAlertViewModel } from '@repo/view-models/ThresholdAlertViewModel';
import { useSignal } from '../hooks/useSignal';
import GreenhouseCard from './GreenhouseCard';
import SensorCard from './SensorCard';
import SensorReadingCard from './SensorReadingCard';
import ThresholdAlertCard from './ThresholdAlertCard';
const Dashboard: React.FC = () => {
// Read ViewModel signals
const greenHouses = useSignal(greenHouseViewModel.data$);
const isLoadingGreenHouses = useSignal(greenHouseViewModel.isLoading$);
const sensors = useSignal(sensorViewModel.data$);
const isLoadingSensors = useSignal(sensorViewModel.isLoading$);
const sensorReadings = useSignal(sensorReadingViewModel.data$);
const isLoadingSensorReadings = useSignal(sensorReadingViewModel.isLoading$);
const thresholdAlerts = useSignal(thresholdAlertViewModel.data$);
const isLoadingThresholdAlerts = useSignal(thresholdAlertViewModel.isLoading$);
// Fetch data on mount
useEffect(() => {
const fetchData = async () => {
try {
await greenHouseViewModel.fetchCommand.execute();
await sensorViewModel.fetchCommand.execute();
await sensorReadingViewModel.fetchCommand.execute();
await thresholdAlertViewModel.fetchCommand.execute();
} catch (error) {
console.error('Error fetching data:', error);
}
};
fetchData();
}, []); // Empty dependency array ensures this runs once on mount
const isLoading =
isLoadingGreenHouses ||
isLoadingSensors ||
isLoadingSensorReadings ||
isLoadingThresholdAlerts;
return (
<div className="dashboard-container">
{isLoading && <p>Loading dashboard data...</p>}
{!isLoading && (
<>
<h2>Dashboard</h2>
<div className="flex-container">
<div className="flex-item">
<GreenhouseCard greenHouses={greenHouses} />
</div>
<div className="flex-item">
<SensorCard sensors={sensors} />
</div>
<div className="flex-item">
<ThresholdAlertCard thresholdAlerts={thresholdAlerts ?? []} />
</div>
<div className="flex-item">
<SensorReadingCard sensorReadings={sensorReadings ?? []} />
</div>
</div>
</>
)}
</div>
);
};
export default Dashboard;Key observations:
- The component imports ViewModels directly—no dependency injection needed
useSignalbridges Web Loom signals into React re-renders viauseSyncExternalStore- Commands are called in
useEffectwith an empty dependency array (runs once on mount) - The component is purely presentational—all logic lives in the ViewModels
- Loading states from multiple ViewModels are combined with simple boolean logic
Vue Implementation: Composition API and Composables
Vue 3's Composition API provides a similar pattern to React hooks. We create a useSignal composable that keeps a shallowRef in sync with a signal.
The useSignal Composable
// apps/mvvm-vue/src/hooks/useSignal.ts
import { shallowRef, onUnmounted, type ShallowRef } from 'vue';
import { observe, type ReadonlySignal } from '@web-loom/signals-core';
export function useSignal<T>(sig: ReadonlySignal<T>): ShallowRef<T> {
const value = shallowRef<T>(sig.peek());
const unsubscribe = observe(sig, (next) => {
value.value = next;
});
onUnmounted(unsubscribe);
return value;
}This composable:
- Creates a reactive
shallowRefseeded with the signal's current value (sig.peek()) - Subscribes via
observe(), which also delivers the current value immediately — keeping the ref accurate from the very first read - Updates the ref on every subsequent change
- Cleans up the subscription when the component unmounts
Vue Dashboard Component
Here's the same Dashboard implemented in Vue:
<!-- apps/mvvm-vue/src/components/Dashboard.vue -->
<template>
<div class="dashboard-container">
<h2>Dashboard</h2>
<div v-if="isLoading" class="loading-message">
<p>Loading dashboard data...</p>
</div>
<div v-if="!isLoading" class="flex-container">
<div className="flex-item">
<GreenhouseCard :greenhouse-list-data-prop="greenHouses" />
</div>
<div className="flex-item">
<SensorCard :sensor-list-data-prop="sensors" />
</div>
<div className="flex-item">
<ThresholdAlertCard :threshold-alerts-prop="thresholdAlerts" />
</div>
<div className="flex-item">
<SensorReadingCard :sensor-readings-prop="sensorReadings" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, computed } from 'vue';
import { useSignal } from '../hooks/useSignal';
import { greenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
import { sensorViewModel } from '@repo/view-models/SensorViewModel';
import { sensorReadingViewModel } from '@repo/view-models/SensorReadingViewModel';
import { thresholdAlertViewModel } from '@repo/view-models/ThresholdAlertViewModel';
import GreenhouseCard from './GreenhouseCard.vue';
import SensorCard from './SensorCard.vue';
import SensorReadingCard from './SensorReadingCard.vue';
import ThresholdAlertCard from './ThresholdAlertCard.vue';
// Read ViewModel signals
const greenHouses = useSignal(greenHouseViewModel.data$);
const isLoadingGreenHouses = useSignal(greenHouseViewModel.isLoading$);
const sensors = useSignal(sensorViewModel.data$);
const isLoadingSensors = useSignal(sensorViewModel.isLoading$);
const sensorReadings = useSignal(sensorReadingViewModel.data$);
const isLoadingSensorReadings = useSignal(sensorReadingViewModel.isLoading$);
const thresholdAlerts = useSignal(thresholdAlertViewModel.data$);
const isLoadingThresholdAlerts = useSignal(thresholdAlertViewModel.isLoading$);
// Computed property for combined loading state
const isLoading = computed(
() =>
isLoadingGreenHouses.value ||
isLoadingSensors.value ||
isLoadingSensorReadings.value ||
isLoadingThresholdAlerts.value,
);
// Fetch data on mount
onMounted(async () => {
try {
await greenHouseViewModel.fetchCommand.execute();
await sensorViewModel.fetchCommand.execute();
await sensorReadingViewModel.fetchCommand.execute();
await thresholdAlertViewModel.fetchCommand.execute();
} catch (error) {
console.error('Error fetching dashboard data:', error);
}
});
</script>
<style scoped></style>Key observations:
- The structure mirrors the React implementation almost exactly
useSignalreturns a VueShallowRefinstead of React statecomputed()creates a derived value (like React'suseMemo) — this is Vue's owncomputed, not@web-loom/signals-core's, though the two are conceptually identicalonMountedreplaces React'suseEffectfor initialization- The same ViewModels work without modification
Angular Implementation: Dependency Injection and Signal Bridging
Angular takes a different approach. Instead of hooks or composables, Angular uses dependency injection to provide ViewModels, and its own native Signals system to read ViewModel state in templates. Since data$/isLoading$/error$ are already @web-loom/signals-core signals — not RxJS Observables — the natural bridge is fromLoomSignal, a small utility that mirrors a Web Loom signal into a native Angular signal.
The fromLoomSignal Bridge
// 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();
}This seeds a native Angular signal from the current value, mirrors every future change into it, and ties teardown to Angular's own DestroyRef — no ngOnDestroy boilerplate required.
Angular Component with DI
// apps/mvvm-angular/src/app/components/greenhouse-card/greenhouse-card.component.ts
import { Component, OnInit, Inject, InjectionToken, Signal, DestroyRef, inject } from '@angular/core';
import { RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common';
import { greenHouseViewModel, GreenhouseListData } from '@repo/view-models/GreenHouseViewModel';
import { fromLoomSignal } from '../../utils/loom-signals';
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 {
// 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);
// Fetch data
this.vm.fetchCommand.execute();
}
}Angular Template — Call the Signal
<!-- 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>Key observations:
- Angular uses
InjectionTokento provide the ViewModel - The component bridges ViewModel signals to native Angular signals with
fromLoomSignal - The template calls the signal as a function (
data$()) — noasyncpipe needed - No manual subscription management—
fromLoomSignalties cleanup toDestroyRefinternally - Structural directives like
*ngIf/*ngForwork fine alongside signal function calls; you don't need the newer@if/@forsyntax to benefit
Framework Comparison: Same ViewModel, Different Views
Let's compare how each framework handles the same task: displaying a list of sensors.
React: Hooks Pattern
// apps/mvvm-react/src/components/SensorList.tsx
import { useEffect } from 'react';
import { sensorViewModel } from '@repo/view-models/SensorViewModel';
import { useSignal } from '../hooks/useSignal';
export function SensorList() {
const sensors = useSignal(sensorViewModel.data$);
const isLoading = useSignal(sensorViewModel.isLoading$);
useEffect(() => {
sensorViewModel.fetchCommand.execute();
}, []);
if (isLoading) return <p>Loading sensors...</p>;
return (
<div>
<h2>Sensors</h2>
<ul>
{sensors.map((sensor) => (
<li key={sensor.id}>
{sensor.name} - {sensor.type}
</li>
))}
</ul>
</div>
);
}Vue: Composables Pattern
<!-- apps/mvvm-vue/src/components/SensorList.vue -->
<template>
<div>
<h2>Sensors</h2>
<p v-if="isLoading">Loading sensors...</p>
<ul v-else>
<li v-for="sensor in sensors" :key="sensor.id">
{{ sensor.name }} - {{ sensor.type }}
</li>
</ul>
</div>
</template>
<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 isLoading = useSignal(sensorViewModel.isLoading$);
onMounted(() => {
sensorViewModel.fetchCommand.execute();
});
</script>Angular: Signal Bridge Pattern
// apps/mvvm-angular/src/app/components/sensor-list/sensor-list.component.ts
import { Component, OnInit, Inject, InjectionToken, Signal, DestroyRef, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { sensorViewModel, SensorListData } from '@repo/view-models/SensorViewModel';
import { fromLoomSignal } from '../../utils/loom-signals';
export const SENSOR_VIEW_MODEL = new InjectionToken<typeof sensorViewModel>(
'SENSOR_VIEW_MODEL'
);
@Component({
selector: 'app-sensor-list',
standalone: true,
imports: [CommonModule],
template: `
<div>
<h2>Sensors</h2>
<p *ngIf="loading$()">Loading sensors...</p>
<ul *ngIf="!loading$()">
<li *ngFor="let sensor of data$()">
{{ sensor.name }} - {{ sensor.type }}
</li>
</ul>
</div>
`,
providers: [
{
provide: SENSOR_VIEW_MODEL,
useValue: sensorViewModel,
},
],
})
export class SensorListComponent implements OnInit {
public data$!: Signal<SensorListData | null>;
public loading$!: Signal<boolean>;
private destroyRef = inject(DestroyRef);
constructor(@Inject(SENSOR_VIEW_MODEL) private vm: typeof sensorViewModel) {}
ngOnInit(): void {
this.data$ = fromLoomSignal(this.vm.data$, this.destroyRef);
this.loading$ = fromLoomSignal(this.vm.isLoading$, this.destroyRef);
this.vm.fetchCommand.execute();
}
}Side-by-Side Comparison
| Aspect | React | Vue | Angular |
|--------|-------|-----|---------|
| Subscription | useSignal hook | useSignal composable | fromLoomSignal bridge |
| Cleanup | useEffect return | onUnmounted | DestroyRef (automatic) |
| State | useState (via useSyncExternalStore) | ShallowRef | Native Angular Signal |
| Initialization | useEffect | onMounted | ngOnInit |
| Template | JSX | Vue template | Angular template, data$() call |
| DI | Direct import | Direct import | InjectionToken |
What's the same:
- The ViewModel (
sensorViewModel) is identical - The signals (
data$,isLoading$) are identical - The commands (
fetchCommand.execute()) are identical - The business logic is identical
What's different:
- How subscriptions are managed (hooks vs composables vs signal bridge)
- How state triggers re-renders (setState vs ref vs Angular's own signal-based change detection)
- How components are structured (functional vs SFC vs class)
This is MVVM's power: write the business logic once, adapt the View layer to each framework.
View Layer Best Practices
Based on our real-world implementations, here are the patterns that work:
1. Keep Views Thin
Views should be as simple as possible. If you find yourself writing complex logic in a component, move it to the ViewModel.
Bad:
// Component contains business logic
function SensorCard({ sensor }) {
const status = sensor.value > sensor.threshold ? 'critical' : 'normal';
const color = status === 'critical' ? 'red' : 'green';
const message = status === 'critical'
? `Alert: ${sensor.name} is ${sensor.value - sensor.threshold} over threshold`
: `${sensor.name} is operating normally`;
return <div style={{ color }}>{message}</div>;
}Good:
// ViewModel contains business logic
class SensorViewModel {
public readonly displayStatus$: ReadonlySignal<{
status: 'critical' | 'normal';
color: string;
message: string;
}>;
constructor(sensor$: ReadonlySignal<Sensor>) {
this.displayStatus$ = computed(() => {
const sensor = sensor$.get();
return {
status: sensor.value > sensor.threshold ? 'critical' : 'normal',
color: sensor.value > sensor.threshold ? 'red' : 'green',
message: sensor.value > sensor.threshold
? `Alert: ${sensor.name} is ${sensor.value - sensor.threshold} over threshold`
: `${sensor.name} is operating normally`
};
});
}
}
// Component just renders
function SensorCard() {
const status = useSignal(sensorViewModel.displayStatus$);
if (!status) return null;
return <div style={{ color: status.color }}>{status.message}</div>;
}2. Subscribe Once, Use Everywhere
Don't subscribe to the same observable multiple times in a component. Subscribe once and use the value.
Bad:
function Dashboard() {
const sensors = useSignal(sensorViewModel.data$);
const sensorCount = useSignal(sensorViewModel.data$).length; // Duplicate subscription
const hasSensors = useSignal(sensorViewModel.data$).length > 0; // Another duplicate
return <div>...</div>;
}Good:
function Dashboard() {
const sensors = useSignal(sensorViewModel.data$);
const sensorCount = sensors.length;
const hasSensors = sensors.length > 0;
return <div>...</div>;
}Or better yet, move derived values to the ViewModel:
class SensorViewModel {
public readonly data$: ReadonlySignal<Sensor[]>;
public readonly count$: ReadonlySignal<number>;
public readonly hasSensors$: ReadonlySignal<boolean>;
constructor() {
this.data$ = /* ... */;
this.count$ = computed(() => this.data$.get().length);
this.hasSensors$ = computed(() => this.count$.get() > 0);
}
}3. Handle Loading and Error States
Always handle loading and error states from ViewModels. Don't assume data is always available.
function SensorList() {
const sensors = useSignal(sensorViewModel.data$);
const isLoading = useSignal(sensorViewModel.isLoading$);
const error = useSignal(sensorViewModel.error$);
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
if (!sensors || sensors.length === 0) return <EmptyState />;
return (
<ul>
{sensors.map(sensor => (
<li key={sensor.id}>{sensor.name}</li>
))}
</ul>
);
}4. Use Commands for User Actions
When users interact with the UI, call ViewModel commands. Don't make API calls or update state directly in the View.
Bad:
function SensorCard({ sensor }) {
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
setIsDeleting(true);
try {
await fetch(`/api/sensors/${sensor.id}`, { method: 'DELETE' });
// Now what? How do we update the list?
} catch (error) {
alert('Failed to delete sensor');
} finally {
setIsDeleting(false);
}
};
return <button onClick={handleDelete}>Delete</button>;
}Good:
function SensorCard({ sensor }) {
const isDeleting = useSignal(sensorViewModel.isDeleting$);
const handleDelete = () => {
sensorViewModel.deleteCommand.execute(sensor.id);
};
return (
<button onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
);
}5. Dispose ViewModels When Done
If you create ViewModel instances (not singletons), dispose them when the component unmounts.
function SensorDetail({ sensorId }) {
const [viewModel] = useState(() => new SensorDetailViewModel(sensorId));
useEffect(() => {
return () => viewModel.dispose(); // Cleanup
}, [viewModel]);
const sensor = useSignal(viewModel.sensor$);
return <div>{sensor?.name}</div>;
}Key Takeaways
The View layer in MVVM is the framework-specific adapter that consumes framework-agnostic ViewModels. By keeping Views thin and delegating all logic to ViewModels, we achieve true framework independence.
The View-ViewModel contract:
- ViewModels expose signals and commands
- Views read signals and invoke commands
- Views handle framework-specific concerns (routing, animations, accessibility)
- ViewModels handle business logic, validation, and state management
Framework patterns:
- React: Use the
useSignalhook (backed byuseSyncExternalStore) for reads - Vue: Use the
useSignalcomposable (backed byshallowRef+observe()) with automaticonUnmountedcleanup - Angular: Use dependency injection with
fromLoomSignalto bridge into native Angular signals
Best practices:
- Keep Views as thin as possible
- Subscribe once, use the value multiple times
- Always handle loading, error, and empty states
- Use ViewModel commands for user actions
- Dispose ViewModels when components unmount
In the next chapter, we'll explore dependency injection and lifecycle management—how to provide ViewModels to Views, manage their lifecycles, and handle cleanup across different frameworks.