Web Loom logo
MVVM CoreMVVM in Vue

MVVM in Vue

How Vue 3's Proxy-based reactivity system works, why @web-loom/signals-core signals need a composable bridge, and practical patterns for wiring ViewModels into Vue components — with real examples from the Web Loom Greenhouse app.

MVVM in Vue

Vue 3 and @web-loom/signals-core are both reactive, but they track and propagate changes in completely different ways. Understanding that difference is what makes the integration feel intentional rather than patched together. This page explains Vue's reactivity system, why signal reads need a composable bridge, and the concrete patterns Web Loom uses throughout the Greenhouse app.


How Vue 3's Reactivity System Works

Vue 3's reactivity is built on JavaScript Proxies. When you wrap a value in ref() or reactive(), Vue creates a Proxy around it. Any time a piece of code reads from that Proxy during a tracked context (a template render, a computed() getter, or watchEffect()), Vue records that read as a dependency. When the Proxy's value later changes, Vue notifies every dependent and schedules a re-render or re-computation.

ref(0)          ← Proxy wrapping a primitive via { value: ... }
reactive({})    ← Proxy wrapping an object directly
computed(fn)    ← runs fn, records dependencies, re-runs on change
watch(source)   ← watches source, runs callback when it changes
watchEffect(fn) ← runs fn immediately, re-runs whenever any dependency changes

ref() and .value

ref() wraps a value in a reactive container:

import { ref, computed, watch } from 'vue';
 
const count = ref(0);
 
count.value++;           // triggers reactivity
console.log(count.value); // 1
 
// In templates, .value is unwrapped automatically:
// <span>{{ count }}</span>  ← no .value needed

reactive()

reactive() makes a plain object deeply reactive:

const form = reactive({ name: '', location: '', size: '' });
 
form.name = 'Alpha House'; // triggers reactivity — no .value needed

Use ref() for primitives and single values; use reactive() for objects that you mutate in place, like form state.

computed()

computed() derives a value from reactive state and caches it until its dependencies change:

const isLoading    = ref(true);
const hasData      = ref(false);
const isReady      = computed(() => !isLoading.value && hasData.value);
// isReady updates automatically when isLoading or hasData change

watch() and watchEffect()

watch() runs a callback when a specific source changes. watchEffect() runs immediately and tracks whichever reactive values it reads:

// watch: explicit source, runs on change
watch(count, (newVal, oldVal) => {
  console.log('count changed from', oldVal, 'to', newVal);
});
 
// watchEffect: runs immediately, auto-tracks dependencies
watchEffect(() => {
  console.log('count is now', count.value);
});

Lifecycle Hooks

In the Composition API, lifecycle hooks are plain functions you call inside <script setup>:

  • onMounted(fn) — runs after the component is inserted into the DOM
  • onUnmounted(fn) — runs just before the component is removed
  • onBeforeUnmount(fn) — runs just before unmount (useful for sync cleanup)

The Mismatch with Signals

Vue's reactivity works by intercepting reads and writes through Proxies. A @web-loom/signals-core signal is a plain JavaScript object with .get()/.peek()/.subscribe() methods — Vue's Proxy has no way to intercept a .subscribe() callback or track when .set() is called on it.

If you read a signal's current value directly in a template, Vue records no dependency on it:

// ❌ Vue tracks nothing here — template never re-renders on changes
const vm = greenHouseViewModel;
// In template: {{ vm.data$.peek() }}  ← stale forever

The fix is to mirror signal changes into a Vue ref — a value that Vue does track. The bridge is a useSignal composable.


The useSignal Composable

From apps/mvvm-vue/src/hooks/useSignal.ts — the single pattern used throughout the Web Loom Vue app:

import { shallowRef, onUnmounted, type ShallowRef } from 'vue';
import { observe, type ReadonlySignal } from '@web-loom/signals-core';
 
/**
 * Bind a Web Loom signal to Vue reactivity. Seeds the ref with the current
 * value (observe delivers immediately) and mirrors every change; the
 * subscription is torn down on component unmount.
 */
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;
}

How it works step by step:

  • shallowRef(sig.peek()) creates a reactive container seeded with the signal's current value. Vue tracks every template expression that reads .value on it. shallowRef (not ref) is used because the signal's own value — often an array or object from a Model — shouldn't be made deeply reactive a second time.
  • observe(sig, fn) — from @web-loom/signals-core — is called immediately when the composable runs (inside <script setup>). Unlike plain sig.subscribe(fn) (which only fires on future changes), observe calls fn once synchronously with the current value first, then subscribes for future changes. That's what makes the initial shallowRef seed and every subsequent update consistent.
  • Each change writes to value.value. Writing to a ref triggers Vue's reactivity system, scheduling a re-render for any template or computed that depends on it.
  • onUnmounted(unsubscribe) ensures the subscription is cleaned up when the component leaves the DOM, preventing memory leaks. unsubscribe is a plain function — no Subscription object.

Usage is one line per signal:

const greenhouses = useSignal(greenHouseViewModel.data$);
const isLoading   = useSignal(greenHouseViewModel.isLoading$);
const error       = useSignal(greenHouseViewModel.error$);

greenhouses, isLoading, and error are now plain Vue refs. Use them in templates without .value, and in <script setup> with .value.


Setting Up a ViewModel

Option 1: Module-Level Singleton

The pattern used throughout the Web Loom Vue app — create the ViewModel once at module scope, import it wherever needed:

// packages/view-models/src/GreenHouseViewModel.ts
import { createReactiveViewModel } from '@web-loom/mvvm-core';
import { greenHouseConfig, GreenhouseListSchema } from '@repo/models';
 
export const greenHouseViewModel = createReactiveViewModel({
  modelConfig: greenHouseConfig,
  schema: GreenhouseListSchema,
});
<script setup lang="ts">
import { greenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
import { useSignal } from '../hooks/useSignal';
 
const greenhouses = useSignal(greenHouseViewModel.data$);
const isLoading   = useSignal(greenHouseViewModel.isLoading$);
</script>

When to use: App-wide shared data (lists, auth state, navigation) that multiple components consume. The ViewModel is a shared reactive store — any component that subscribes sees the same data, and mutations in one component propagate to all.

Option 2: Per-Component Composable

Create and dispose a ViewModel per component instance:

// composables/useTaskViewModel.ts
import { onUnmounted } from 'vue';
import { TaskViewModel } from './TaskViewModel';
import { TaskModel } from '@repo/models';
 
export function useTaskViewModel(taskId: string) {
  const vm = new TaskViewModel(new TaskModel(taskId));
 
  onUnmounted(() => vm.dispose());
 
  return vm;
}
<script setup lang="ts">
import { useTaskViewModel } from '../composables/useTaskViewModel';
import { useSignal } from '../hooks/useSignal';
 
const props = defineProps<{ taskId: string }>();
const vm    = useTaskViewModel(props.taskId);
 
const task      = useSignal(vm.data$);
const isLoading = useSignal(vm.isLoading$);
 
onMounted(() => vm.fetchCommand.execute());
</script>

When to use: Detail pages or list items that need isolated, independent state per component. onUnmounted disposes the ViewModel cleanly.

Option 3: provide / inject

Share a ViewModel across a component subtree without prop-drilling:

// Parent component or plugin
import { provide } from 'vue';
import { authViewModel } from '@repo/view-models/AuthViewModel';
 
// In a parent component's setup()
provide('authViewModel', authViewModel);
// Child component, any depth
import { inject } from 'vue';
 
const authVm = inject('authViewModel');
const isAuthenticated = useSignal(authVm.isAuthenticated$);

For type safety, use an InjectionKey:

// auth-key.ts
import { type InjectionKey } from 'vue';
import type { AuthViewModel } from '@repo/view-models/AuthViewModel';
 
export const AuthViewModelKey: InjectionKey<AuthViewModel> = Symbol('AuthViewModel');
// Parent
provide(AuthViewModelKey, authViewModel);
 
// Child
const authVm = inject(AuthViewModelKey)!;

When to use: Cross-cutting concerns shared within a feature subtree (e.g. a wizard, a dashboard section) where you want to avoid direct module imports but don't need global singleton behaviour.


Triggering Fetches with onMounted

Call ViewModel commands in onMounted to fire after the component is inserted into the DOM:

<script setup lang="ts">
import { onMounted } from 'vue';
import { greenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
 
onMounted(() => {
  greenHouseViewModel.fetchCommand.execute();
});
</script>

For multiple parallel fetches on a dashboard — from apps/mvvm-vue/src/components/Dashboard.vue:

<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';
 
const greenHouses    = useSignal(greenHouseViewModel.data$);
const sensors        = useSignal(sensorViewModel.data$);
const sensorReadings = useSignal(sensorReadingViewModel.data$);
const thresholdAlerts = useSignal(thresholdAlertViewModel.data$);
 
const isLoadingGreenHouses    = useSignal(greenHouseViewModel.isLoading$);
const isLoadingSensors        = useSignal(sensorViewModel.isLoading$);
const isLoadingSensorReadings = useSignal(sensorReadingViewModel.isLoading$);
const isLoadingAlerts         = useSignal(thresholdAlertViewModel.isLoading$);
 
// computed() derives a combined loading flag from four reactive refs
const isLoading = computed(
  () => isLoadingGreenHouses.value
     || isLoadingSensors.value
     || isLoadingSensorReadings.value
     || isLoadingAlerts.value
);
 
onMounted(async () => {
  await Promise.all([
    greenHouseViewModel.fetchCommand.execute(),
    sensorViewModel.fetchCommand.execute(),
    sensorReadingViewModel.fetchCommand.execute(),
    thresholdAlertViewModel.fetchCommand.execute(),
  ]);
});
</script>

computed(() => ...) automatically re-evaluates whenever any of the loading refs changes — no manual watcher needed.


computed() for Derived State

computed() is the natural fit for transforming or filtering signal-sourced refs. From apps/mvvm-vue/src/components/SensorList.vue:

<script setup lang="ts">
import { onMounted, computed } from 'vue';
import { sensorViewModel } from '@repo/view-models/SensorViewModel';
import { useSignal } from '../hooks/useSignal';
 
const props = defineProps<{ greenhouseId?: string }>();
 
const allSensors = useSignal(sensorViewModel.data$);
 
// Filter client-side based on a prop — recomputes whenever allSensors or greenhouseId changes
const filteredSensors = computed(() => {
  if (!allSensors.value) return [];
  if (!props.greenhouseId) return allSensors.value;
  return allSensors.value.filter(
    s => s.greenhouseId === props.greenhouseId
      || s.greenhouse?.id === props.greenhouseId
  );
});
 
onMounted(() => sensorViewModel.fetchCommand.execute());
</script>
 
<template>
  <ul>
    <li v-for="sensor in filteredSensors" :key="sensor.id">
      {{ sensor.type }} — {{ sensor.status }}
    </li>
  </ul>
</template>

filteredSensors recomputes automatically when either allSensors.value (driven by the ViewModel) or props.greenhouseId changes. No watch() needed for this pattern.


watch() for Prop-Driven Refetches

When a prop change should trigger a new API call, use watch():

<script setup lang="ts">
import { onMounted, watch } from 'vue';
 
const props = defineProps<{ sensorId?: string }>();
 
onMounted(() => sensorReadingViewModel.fetchCommand.execute());
 
// Refetch when the route changes to a different sensorId
watch(
  () => props.sensorId,
  (newId, oldId) => {
    if (newId !== oldId) {
      sensorReadingViewModel.fetchCommand.execute();
    }
  }
);
</script>

For watching deeply nested objects use { deep: true }. For running the callback immediately on mount use { immediate: true } instead of a separate onMounted.


Full Example: Greenhouse CRUD

From apps/mvvm-vue/src/components/GreenhouseList.vue — a complete CRUD form with v-model form binding and ViewModel commands:

<template>
  <section class="flex-container flex-row">
 
    <!-- Form: v-model binds to reactive() form state -->
    <form class="form-container" @submit.prevent="handleSubmit">
      <div class="form-group">
        <label for="name">Greenhouse Name</label>
        <input id="name" v-model="formData.name" required placeholder="e.g. Alpha House" />
      </div>
      <div class="form-group">
        <label for="location">Location</label>
        <textarea id="location" v-model="formData.location" required />
      </div>
      <div class="form-group">
        <label for="size">Size</label>
        <select id="size" v-model="formData.size" required>
          <option value="25sqm">25 sqm — Small</option>
          <option value="50sqm">50 sqm — Medium</option>
          <option value="100sqm">100 sqm — Large</option>
        </select>
      </div>
      <div class="form-group">
        <label for="cropType">Crop Type</label>
        <input id="cropType" v-model="formData.cropType" placeholder="e.g. Tomatoes" />
      </div>
      <button type="submit">{{ editingId ? 'Update' : 'Create' }}</button>
    </form>
 
    <!-- List: renders from ViewModel signal -->
    <div class="card">
      <h1>Greenhouses</h1>
      <p v-if="isLoading">Loading…</p>
      <ul v-else-if="greenhouses && greenhouses.length">
        <li v-for="gh in greenhouses" :key="gh.id">
          <span>{{ gh.name }}</span>
          <button @click="handleEdit(gh.id)">Edit</button>
          <button @click="handleDelete(gh.id)">Delete</button>
        </li>
      </ul>
      <p v-else>No greenhouses yet.</p>
    </div>
 
  </section>
</template>
 
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { greenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
import { useSignal } from '../hooks/useSignal';
 
// Read ViewModel signals → reactive Vue refs
const greenhouses = useSignal(greenHouseViewModel.data$);
const isLoading   = useSignal(greenHouseViewModel.isLoading$);
 
// Local form state — reactive() suits objects mutated in-place
const formData = reactive({ name: '', location: '', size: '', cropType: '' });
const editingId = ref<string | null>(null);
 
onMounted(() => greenHouseViewModel.fetchCommand.execute());
 
function resetForm() {
  Object.assign(formData, { name: '', location: '', size: '', cropType: '' });
  editingId.value = null;
}
 
function handleSubmit() {
  const payload = { ...formData };
 
  if (editingId.value) {
    greenHouseViewModel.updateCommand.execute({ id: editingId.value, payload });
  } else {
    greenHouseViewModel.createCommand.execute(payload);
  }
  resetForm();
}
 
function handleEdit(id?: string) {
  const gh = greenhouses.value?.find(g => g.id === id);
  if (!gh) return;
  Object.assign(formData, {
    name: gh.name,
    location: gh.location,
    size: gh.size,
    cropType: gh.cropType ?? '',
  });
  editingId.value = id ?? null;
}
 
function handleDelete(id?: string) {
  if (!id) return;
  greenHouseViewModel.deleteCommand.execute(id);
}
</script>

Notice what the component does not contain:

  • No fetch() or axios calls
  • No try/catch around mutations
  • No manual loading state variables
  • No optimistic update logic

All of that lives in RestfulApiViewModel and RestfulApiModel. The template is purely declarative.


Binding Command State to the Template

Commands expose isExecuting$ and canExecute$ signals. Read them with useSignal and bind to the template:

<script setup lang="ts">
import { useSignal } from '../hooks/useSignal';
 
const isSaving  = useSignal(vm.saveCommand.isExecuting$);
const canSave   = useSignal(vm.saveCommand.canExecute$);
const saveError = useSignal(vm.saveCommand.executeError$);
</script>
 
<template>
  <button @click="vm.saveCommand.execute()" :disabled="!canSave || isSaving">
    {{ isSaving ? 'Saving…' : 'Save' }}
  </button>
  <p v-if="saveError" class="error">{{ saveError.message }}</p>
</template>

The template remains declarative. No v-on:click handler contains any async logic — that lives in the Command.


reactive() vs ref() for Local State

The Web Loom Vue app uses both consistently:

  • reactive() for form objects — you mutate fields in place and v-model binds to them naturally
  • ref() for single control values — a selected ID, a boolean flag, a string filter
// Form state — reactive() because you mutate many fields in-place
const formData = reactive({ name: '', location: '', size: '' });
Object.assign(formData, { name: 'Beta', location: 'Zone B', size: '50sqm' });
 
// Control state — ref() because it's a single value
const editingId    = ref<string | null>(null);
const showDialog   = ref(false);
const searchFilter = ref('');

Do not put reactive objects inside ref() — Vue's reactivity handles the nesting automatically with reactive(), and double-wrapping adds confusion.


The MaybeRef Pattern (Advanced Composables)

The packages/media-vue package shows a more advanced composable convention — accepting either a raw value or a Ref<T> as input:

// packages/media-vue/src/internal.ts
import { isRef, ref, type Ref } from 'vue';
 
export type MaybeRef<T> = T | Ref<T>;
 
export function toReactiveRef<T>(value?: MaybeRef<T>): Ref<T> {
  return isRef(value) ? value : ref(value as T);
}

This lets composable callers pass either:

// Raw value — composable wraps it in a ref internally
useMediaPlayer({ kind: 'video', sources: [...] });
 
// Already a ref — composable uses it directly, stays reactive to upstream changes
const config = ref({ kind: 'video', sources: [...] });
useMediaPlayer(config);

Use this pattern when writing composables that need to accept config that might change reactively.


watch() with onCleanup (Advanced)

In packages/media-vue/src/useMediaState.ts, the watch() callback receives an onCleanup argument — a function to register cleanup logic that runs before the next invocation or on unmount:

import { watch, ref } from 'vue';
 
export function useMediaState(playerRef: MaybeRef<MediaCorePlayer | null>) {
  const snapshot = ref<PlaybackSnapshot | null>(null);
  const source   = toReactiveRef(playerRef);
 
  watch(source, (player, _prev, onCleanup) => {
    if (!player) {
      snapshot.value = null;
      return;
    }
 
    snapshot.value = player.getState();
 
    // Subscribe to player events
    const disposers = SNAPSHOT_EVENTS.map(event =>
      player.on(event, nextState => { snapshot.value = nextState; })
    );
 
    // Runs before next watch invocation or on unmount
    onCleanup(() => disposers.forEach(dispose => dispose()));
 
  }, { immediate: true });
 
  return snapshot;
}

This is the Vue equivalent of React's useEffect cleanup function. Use it whenever a watch callback opens a subscription or attaches a listener.


Template Directives and the MVVM Pattern

Vue's template directives map cleanly to ViewModel signals:

  • v-if="isLoading" — conditional on a ref from useSignal(vm.isLoading$)
  • v-else-if="error" — conditional on a ref from useSignal(vm.error$)
  • v-for="item in items" — iterates a ref from useSignal(vm.data$)
  • @click="vm.deleteCommand.execute(item.id)" — calls a ViewModel command
  • @submit.prevent="handleSubmit" — calls a function that delegates to ViewModel commands
  • :disabled="!canExecute || isExecuting" — binds command state refs to button attributes
  • v-model="formData.name" — binds to local reactive() form state
<template>
  <div v-if="isLoading">Loading…</div>
  <div v-else-if="error">{{ error.message }}</div>
  <ul v-else>
    <li v-for="item in items" :key="item.id">
      {{ item.name }}
      <button
        @click="vm.deleteCommand.execute(item.id)"
        :disabled="!canDelete || isDeleting"
      >
        {{ isDeleting ? 'Deleting…' : 'Delete' }}
      </button>
    </li>
  </ul>
</template>

The template does no logic — it only reflects reactive state. All decisions live in the ViewModel.


Testing Vue + MVVM Components

Testing a Component with a Mocked ViewModel

import { mount } from '@vue/test-utils';
import { signal } from '@web-loom/signals-core';
import { describe, it, expect, vi } from 'vitest';
import GreenhouseCard from './GreenhouseCard.vue';
 
const mockVm = {
  data$:      signal([{ id: '1', name: 'Alpha', location: 'Zone A' }]),
  isLoading$: signal(false),
  error$:     signal(null),
  fetchCommand: { execute: vi.fn().mockResolvedValue(undefined) },
};
 
it('renders greenhouse names from ViewModel data', async () => {
  const wrapper = mount(GreenhouseCard, {
    props: { vm: mockVm },
  });
  expect(wrapper.text()).toContain('Alpha');
});
 
it('shows loading state when isLoading$ changes to true', async () => {
  mockVm.isLoading$.set(true);
  const wrapper = mount(GreenhouseCard, { props: { vm: mockVm } });
  await wrapper.vm.$nextTick();
  expect(wrapper.text()).toContain('Loading');
});
 
it('updates when data$ changes', async () => {
  const wrapper = mount(GreenhouseCard, { props: { vm: mockVm } });
 
  mockVm.data$.set([
    { id: '1', name: 'Alpha', location: 'Zone A' },
    { id: '2', name: 'Beta',  location: 'Zone B' },
  ]);
 
  await wrapper.vm.$nextTick();
  expect(wrapper.text()).toContain('Beta');
});

$nextTick() waits for Vue to flush DOM updates after a reactive change — equivalent to React's act().

Testing Command Execution

it('calls fetchCommand.execute on mount', () => {
  mount(GreenhouseList, { props: { vm: mockVm } });
  expect(mockVm.fetchCommand.execute).toHaveBeenCalledOnce();
});
 
it('calls deleteCommand when Delete clicked', async () => {
  mockVm.deleteCommand = { execute: vi.fn().mockResolvedValue(undefined) };
  const wrapper = mount(GreenhouseList, { props: { vm: mockVm } });
 
  await wrapper.find('button.delete').trigger('click');
  await wrapper.vm.$nextTick();
 
  expect(mockVm.deleteCommand.execute).toHaveBeenCalledWith('1');
});

Testing Composables Directly

Test useSignal independently of any component:

import { mount } from '@vue/test-utils';
import { defineComponent } from 'vue';
import { signal } from '@web-loom/signals-core';
import { useSignal } from './useSignal';
 
it('useSignal returns the current value synchronously', () => {
  const source = signal('hello');
  const TestComponent = defineComponent({
    setup() {
      const value = useSignal(source);
      return { value };
    },
    template: '<span>{{ value }}</span>',
  });
 
  const wrapper = mount(TestComponent);
  expect(wrapper.text()).toBe('hello'); // observe() delivers the current value immediately
});
 
it('useSignal updates on change', async () => {
  const source = signal('hello');
  const TestComponent = defineComponent({
    setup() { return { value: useSignal(source) }; },
    template: '<span>{{ value }}</span>',
  });
 
  const wrapper = mount(TestComponent);
  source.set('world');
  await wrapper.vm.$nextTick();
  expect(wrapper.text()).toBe('world');
});
 
it('useSignal unsubscribes on unmount', async () => {
  const source = signal(0);
  const wrapper = mount(/* TestComponent */);
  wrapper.unmount();
 
  // Should not throw; subscription should be cleaned up
  expect(() => source.set(1)).not.toThrow();
});

Dos and Don'ts

Do: Use useSignal to Read Every Signal

<!-- ✅ Good — Vue reactivity tracks the ref -->
<script setup>
const items = useSignal(vm.data$);
</script>
<template>
  <li v-for="item in items" :key="item.id">{{ item.name }}</li>
</template>
<!-- ❌ Bad — Vue tracks nothing, template never updates -->
<script setup>
const items = vm.data$.peek();
</script>

Do: Use computed() for Derived State — Not watch()

// ✅ Good — computed caches and updates automatically
const activeSensors = computed(() =>
  allSensors.value?.filter(s => s.status === 'active') ?? []
);
// ❌ Unnecessary — watch + ref for something computed can do
const activeSensors = ref([]);
watch(allSensors, val => {
  activeSensors.value = val?.filter(s => s.status === 'active') ?? [];
});

Do: Use reactive() for Form Objects, ref() for Single Values

// ✅ Good
const formData  = reactive({ name: '', location: '' });   // form object
const editingId = ref<string | null>(null);                // single value
// ❌ Confusing — don't wrap reactive objects in ref
const formData = ref(reactive({ name: '', location: '' }));

Do: Dispose Per-Component ViewModels in onUnmounted

// ✅ Good
const vm = new TaskViewModel(new TaskModel(props.id));
onUnmounted(() => vm.dispose());
// ❌ Memory leak — registered Commands and manual subscriptions never torn down
const vm = new TaskViewModel(new TaskModel(props.id));
// nothing disposes it

Do: Use onCleanup in watch() When Subscribing Inside It

// ✅ Good — listeners cleaned up before next invocation
watch(playerRef, (player, _prev, onCleanup) => {
  const disposer = player?.on('play', handler);
  onCleanup(() => disposer?.());
});
// ❌ Bad — listeners accumulate on every player change
watch(playerRef, (player) => {
  player?.on('play', handler); // never removed
});

Don't: Call execute() at the Top Level of setup()

// ❌ Bad — runs during SSR, before mount, and on every HMR reload
const { setup } = defineComponent({
  setup() {
    vm.fetchCommand.execute(); // fires at wrong time
  }
});
// ✅ Good
onMounted(() => vm.fetchCommand.execute());

Don't: Mutate Signal-Sourced Refs Directly

// ❌ Bad — the next change from the ViewModel overwrites your mutation
const greenhouses = useSignal(vm.data$);
greenhouses.value.push(newItem); // ← don't mutate the ref directly
// ✅ Good — let the ViewModel command handle mutation; ref updates via the signal
await vm.createCommand.execute(newItem);
// ViewModel updates data$ → useSignal updates the ref automatically

Where to Go Next

  • MVVM in Angular — Angular services, signal bridging, and the OnPush change detection strategy
  • MVVM in React — React's virtual DOM model and useSyncExternalStore integration
  • ViewModels — Commands, derived signals, and disposal patterns
  • Models — The data layer that ViewModels read signals from
  • Signals Core — the signal/computed/effect primitives underlying mvvm-core
Was this helpful?
Web Loom logo
Copyright © Web Loom. All rights reserved.