Web Loom logo
Chapter 06Core Patterns

The View Layer Contract

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 data
  • isLoading$: ReadonlySignal<boolean> - Loading state
  • error$: ReadonlySignal<any> - Error state
  • fetchCommand.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:

  1. Reads the current value synchronously via sig.get — no initial-value parameter, no first-render flash
  2. Subscribes to the signal via sig.subscribe, which useSyncExternalStore manages internally
  3. Re-renders the component on every change
  4. 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
  • useSignal bridges Web Loom signals into React re-renders via useSyncExternalStore
  • Commands are called in useEffect with 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:

  1. Creates a reactive shallowRef seeded with the signal's current value (sig.peek())
  2. Subscribes via observe(), which also delivers the current value immediately — keeping the ref accurate from the very first read
  3. Updates the ref on every subsequent change
  4. 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
  • useSignal returns a Vue ShallowRef instead of React state
  • computed() creates a derived value (like React's useMemo) — this is Vue's own computed, not @web-loom/signals-core's, though the two are conceptually identical
  • onMounted replaces React's useEffect for 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 InjectionToken to provide the ViewModel
  • The component bridges ViewModel signals to native Angular signals with fromLoomSignal
  • The template calls the signal as a function (data$()) — no async pipe needed
  • No manual subscription management—fromLoomSignal ties cleanup to DestroyRef internally
  • Structural directives like *ngIf/*ngFor work fine alongside signal function calls; you don't need the newer @if/@for syntax 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 useSignal hook (backed by useSyncExternalStore) for reads
  • Vue: Use the useSignal composable (backed by shallowRef + observe()) with automatic onUnmounted cleanup
  • Angular: Use dependency injection with fromLoomSignal to 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.

Web Loom logo
Copyright © Web Loom. All rights reserved.