Web Loom logo
MVVM CoreMVVM in React

MVVM in React

How React's rendering model works, why external signals need a bridge, and practical patterns for wiring ViewModels into React 19 components — with real examples from the Web Loom Greenhouse app.

MVVM in React

React's rendering model and mvvm-core's signals operate on different mechanisms for triggering updates. Understanding that gap is what makes MVVM feel natural in React rather than awkward. This page explains React's rendering system, why signals don't plug straight in, and the concrete patterns Web Loom uses to bridge them.


How React's Rendering Model Works

React components are pure functions of state. Given the same state, a component always renders the same output. React controls when components re-render:

  • When useState or useReducer state changes
  • When props from the parent change
  • When context value changes

The Virtual DOM and Reconciliation

When state changes, React calls your component function again, producing a new virtual DOM tree — a lightweight JavaScript description of what the UI should look like. React then diffs the new tree against the previous one (reconciliation) and applies only the changed parts to the real DOM.

State change
    ↓
Component function runs → new virtual DOM tree
    ↓
React diffs old tree vs new tree
    ↓
Minimal DOM patch applied

This is efficient, but it has an important implication: React only re-renders when React knows state has changed. External values — like a @web-loom/signals-core signal, a WebSocket, or any value outside React's state system — are invisible to React unless you explicitly tell it about them.

The External Store Problem

A signal from @web-loom/mvvm-core holds a value and notifies subscribers. React has no idea this value exists:

// React is completely unaware of this update
const count = signal(0);
count.set(1); // notifies signal subscribers, NOT React

If a component reads count.peek() directly, it will show the initial value forever — React never knows it needs to re-render.


Bridging Signals to React

The fix is to connect signal changes to a React re-render. useSyncExternalStore (React 18+) is built exactly for this: subscribing to an external data source without risking tearing — a scenario where different parts of the UI read different snapshots of the same store during a single render pass.

// apps/mvvm-react/src/hooks/useSignal.ts
import { useSyncExternalStore } from 'react';
import type { ReadonlySignal } from '@web-loom/signals-core';
 
/**
 * Bind a Web Loom signal to React. Reads the current value synchronously
 * (no initial-value parameter, no first-render flash) and re-renders on
 * every change via useSyncExternalStore.
 */
export function useSignal<T>(sig: ReadonlySignal<T>): T {
  return useSyncExternalStore(sig.subscribe, sig.get, sig.get);
}

How it works:

  • The first argument to useSyncExternalStore is a subscribe function — signals already expose one directly as sig.subscribe, so it's passed straight through
  • The second and third arguments are getSnapshot functions (client and server) — sig.get reads the current value synchronously, so no separate initialValue parameter is needed and there's no first-render flash of a placeholder
  • React guarantees that all components reading the same signal see a consistent snapshot within a single render

Usage:

function GreenhouseList() {
  const greenHouses = useSignal(vm.data$);
  const isLoading = useSignal(vm.isLoading$);
  const error = useSignal(vm.error$);
 
  // greenHouses, isLoading, error are now plain React values —
  // they trigger re-renders automatically when the ViewModel's signals change.
}

Setting Up a ViewModel

Option 1: Module-Level Singleton

The simplest pattern — create the ViewModel once at the module level, shared across all components that import it:

// packages/view-models/src/GreenHouseViewModel.ts
import { createReactiveViewModel } from '@web-loom/mvvm-core';
import { greenHouseConfig, GreenhouseListSchema, type GreenhouseListData } from '@repo/models';
 
export const greenHouseViewModel = createReactiveViewModel({
  modelConfig: greenHouseConfig,
  schema: GreenhouseListSchema,
});
// Any component can import and use it directly
import { computed } from '@web-loom/signals-core';
import { greenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
import { useSignal } from '../hooks/useSignal';
 
const greenhouseCount$ = computed(() => greenHouseViewModel.data$.get()?.length ?? 0);
 
function GreenhouseSummary() {
  const count = useSignal(greenhouseCount$);
  return <span>{count} greenhouses</span>;
}

When to use: App-wide data (auth state, navigation, global lists) that multiple components consume. The ViewModel acts as a shared reactive store — no prop-drilling required.

Option 2: Per-Component Instance (useMemo)

Create a fresh ViewModel per component mount using useMemo:

import { useMemo, useEffect } from 'react';
import { TaskViewModel } from './TaskViewModel';
import { TaskModel } from '@repo/models';
 
function TaskDetail({ taskId }: { taskId: string }) {
  const vm = useMemo(() => new TaskViewModel(new TaskModel(taskId)), [taskId]);
 
  useEffect(() => {
    vm.fetchCommand.execute();
    return () => vm.dispose(); // clean up on unmount or taskId change
  }, [vm]);
 
  const task = useSignal(vm.data$);
  const isLoading = useSignal(vm.isLoading$);
 
  if (isLoading) return <Spinner />;
  return <div>{task?.title}</div>;
}

When to use: Detail pages or list items where each instance needs its own independent state. useMemo ensures the ViewModel is only created once per mount (not on every render), and useEffect disposes it when the component unmounts.

Option 3: Context + Provider

Bridge a ViewModel's signal state into React Context so any descendant can access it without prop-drilling:

// providers/AuthProvider.tsx
import { createContext, useContext } from 'react';
import { authViewModel } from '@repo/view-models/AuthViewModel';
import { useSignal } from '../hooks/useSignal';
 
interface AuthContextValue {
  isAuthenticated: boolean;
  isLoading: boolean;
}
 
const AuthContext = createContext<AuthContextValue | null>(null);
 
export function AuthProvider({ children }: { children: React.ReactNode }) {
  // Subscribe once at the provider level — all consumers share this subscription
  const isAuthenticated = useSignal(authViewModel.isAuthenticated$);
  const isLoading = useSignal(authViewModel.isLoading$);
 
  return (
    <AuthContext.Provider value={{ isAuthenticated, isLoading }}>
      {children}
    </AuthContext.Provider>
  );
}
 
export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used within AuthProvider');
  return ctx;
}
// App.tsx — wrap the tree
<ThemeProvider>
  <AppProvider>
    <AuthProvider>
      <BrowserRouter>
        <Routes>...</Routes>
      </BrowserRouter>
    </AuthProvider>
  </AppProvider>
</ThemeProvider>
// Any component in the tree
function Header() {
  const { isAuthenticated } = useAuth(); // no direct ViewModel import needed
  return isAuthenticated ? <UserMenu /> : <SignInButton />;
}

When to use: Cross-cutting concerns like authentication, theme, or permissions. The ViewModel subscription lives in the provider, and all consumers read plain values from context.


Triggering Fetches on Mount

Use useEffect with an empty dependency array to fire a command once when the component mounts:

useEffect(() => {
  greenHouseViewModel.fetchCommand.execute();
}, []);

For per-component ViewModels (Option 2), include the vm in the dependency array and return dispose() in the cleanup:

useEffect(() => {
  vm.fetchCommand.execute();
  return () => vm.dispose();
}, [vm]);

For multiple parallel fetches on a dashboard:

useEffect(() => {
  const fetchAll = async () => {
    await Promise.all([
      greenHouseViewModel.fetchCommand.execute(),
      sensorViewModel.fetchCommand.execute(),
      sensorReadingViewModel.fetchCommand.execute(),
      thresholdAlertViewModel.fetchCommand.execute(),
    ]);
  };
  fetchAll();
}, []);

Binding Commands to the UI

Commands expose three signals that map directly to common UI states:

  • isExecuting$ → loading spinner / button text
  • canExecute$ → button disabled prop
  • executeError$ → error message display
function SaveButton({ vm }: { vm: DocumentViewModel }) {
  const canSave = useSignal(vm.saveCommand.canExecute$);
  const isSaving = useSignal(vm.saveCommand.isExecuting$);
  const saveError = useSignal(vm.saveCommand.executeError$);
 
  return (
    <>
      <button
        onClick={() => vm.saveCommand.execute()}
        disabled={!canSave || isSaving}
      >
        {isSaving ? 'Saving…' : 'Save'}
      </button>
      {saveError && <p className="error">{saveError.message}</p>}
    </>
  );
}

The View has no try/catch, no manual isLoading state, and no error tracking — the Command owns all of it.


Full Example: Greenhouse CRUD

From apps/mvvm-react/src/components/GreenhouseList.tsx — a full CRUD component using a module-level ViewModel:

import { useEffect } from 'react';
import { greenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
import { useSignal } from '../hooks/useSignal';
 
export function GreenhouseList() {
  // Read ViewModel signals
  const greenHouses = useSignal(greenHouseViewModel.data$);
  const isLoading = useSignal(greenHouseViewModel.isLoading$);
 
  // Fetch on mount
  useEffect(() => {
    greenHouseViewModel.fetchCommand.execute();
  }, []);
 
  const handleSubmit = (event: React.FormEvent) => {
    event.preventDefault();
    const formData = new FormData(event.target as HTMLFormElement);
    const payload = {
      name: formData.get('name') as string,
      location: formData.get('location') as string,
      size: formData.get('size') as string,
      cropType: formData.get('cropType') as string,
    };
 
    // Check if greenhouse already exists → update instead of create
    const existing = greenHouses?.find(gh => gh.name === payload.name);
    if (existing) {
      greenHouseViewModel.updateCommand.execute({ id: existing.id!, payload });
      return;
    }
    greenHouseViewModel.createCommand.execute(payload);
  };
 
  const handleDelete = (id?: string) => {
    if (!id) return;
    greenHouseViewModel.deleteCommand.execute(id);
  };
 
  if (isLoading) return <p>Loading…</p>;
 
  return (
    <section>
      <form onSubmit={handleSubmit}>
        <input name="name" placeholder="Greenhouse name" required />
        <textarea name="location" placeholder="Location" required />
        <select name="size" required>
          <option value="25sqm">25 sqm — Small</option>
          <option value="50sqm">50 sqm — Medium</option>
          <option value="100sqm">100 sqm — Large</option>
        </select>
        <input name="cropType" placeholder="Crop type" />
        <button type="submit">Save</button>
      </form>
 
      <ul>
        {greenHouses?.map(gh => (
          <li key={gh.id}>
            <span>{gh.name}</span>
            <button onClick={() => handleDelete(gh.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </section>
  );
}

Notice what the component does not contain:

  • No try/catch around API calls
  • No manual isLoading state management
  • No error tracking variables
  • No axios or fetch calls

All of that lives in RestfulApiViewModel and RestfulApiModel.


Dashboard: Multiple ViewModels, One Component

From apps/mvvm-react/src/components/Dashboard.tsx:

import { 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';
 
const Dashboard = () => {
  const greenHouses    = useSignal(greenHouseViewModel.data$);
  const sensors        = useSignal(sensorViewModel.data$);
  const sensorReadings = useSignal(sensorReadingViewModel.data$);
  const alerts         = useSignal(thresholdAlertViewModel.data$);
 
  const isLoading =
    useSignal(greenHouseViewModel.isLoading$) ||
    useSignal(sensorViewModel.isLoading$) ||
    useSignal(sensorReadingViewModel.isLoading$) ||
    useSignal(thresholdAlertViewModel.isLoading$);
 
  useEffect(() => {
    Promise.all([
      greenHouseViewModel.fetchCommand.execute(),
      sensorViewModel.fetchCommand.execute(),
      sensorReadingViewModel.fetchCommand.execute(),
      thresholdAlertViewModel.fetchCommand.execute(),
    ]);
  }, []);
 
  if (isLoading) return <p>Loading dashboard…</p>;
 
  return (
    <div className="dashboard-grid">
      <GreenhouseCard greenHouses={greenHouses} />
      <SensorCard sensors={sensors} />
      <AlertCard alerts={alerts} />
      <ReadingCard readings={sensorReadings} />
    </div>
  );
};

Each ViewModel is independent. The Dashboard just subscribes to their data streams and delegates rendering to child components — none of which know anything about the data source.


Auth Gate: Commands in a Form

From apps/mvvm-react-integrated/src/components/AuthGate.tsx — a sign-in / sign-up form wired entirely to ViewModel commands:

import { useState, type FormEvent } from 'react';
import { authViewModel } from '@repo/view-models/AuthViewModel';
import { useAuth } from '../providers/AuthProvider';
 
export function AuthGate() {
  const [mode, setMode] = useState<'signin' | 'signup'>('signin');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
 
  // Read loading state from context (which reads from authViewModel.isLoading$)
  const { isLoading } = useAuth();
 
  const handleSubmit = async (event: FormEvent) => {
    event.preventDefault();
    setErrorMessage(null);
    try {
      if (mode === 'signin') {
        await authViewModel.signInCommand.execute({ email, password });
      } else {
        await authViewModel.signUpCommand.execute({ email, password });
      }
    } catch (error) {
      setErrorMessage(error instanceof Error ? error.message : 'Authentication failed');
    }
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
      <input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
      {errorMessage && <p className="error">{errorMessage}</p>}
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Processing…' : mode === 'signin' ? 'Sign in' : 'Sign up'}
      </button>
    </form>
  );
}

Local React state (email, password, errorMessage) manages the form inputs — that's correct, it's transient UI state. The ViewModel command handles the async work and updates the shared isAuthenticated$ / isLoading$ signals that flow through AuthProvider to any component in the tree.


CompositeCommand: Orchestrating Multiple Operations

CompositeCommand groups several Commands and executes them in parallel or sequentially. From apps/mvvm-react-integrated/src/components/CompositeCommandPanel.tsx:

import { useMemo, useEffect } from 'react';
import { CompositeCommand } from '@web-loom/mvvm-core';
import { useSignal } from '../hooks/useSignal';
 
function CompositeCommandPanel() {
  // Parallel: all four fetches fire at once
  const parallelRefresh = useMemo(() => {
    const cmd = new CompositeCommand<void, void[]>({ executionMode: 'parallel' });
    cmd.register(greenHouseViewModel.fetchCommand);
    cmd.register(sensorViewModel.fetchCommand);
    cmd.register(sensorReadingViewModel.fetchCommand);
    cmd.register(thresholdAlertViewModel.fetchCommand);
    return cmd;
  }, []);
 
  // Sequential: each step completes before the next starts
  const diagnosticSequence = useMemo(() => {
    const cmd = new CompositeCommand<void, void[]>({ executionMode: 'sequential' });
    cmd.register(sensorReadingViewModel.fetchCommand);
    cmd.register(thresholdAlertViewModel.fetchCommand);
    return cmd;
  }, []);
 
  // Cleanup on unmount
  useEffect(() => {
    return () => {
      parallelRefresh.dispose();
      diagnosticSequence.dispose();
    };
  }, [parallelRefresh, diagnosticSequence]);
 
  const parallelRunning  = useSignal(parallelRefresh.isExecuting$);
  const parallelCanRun   = useSignal(parallelRefresh.canExecute$);
  const sequenceRunning  = useSignal(diagnosticSequence.isExecuting$);
  const sequenceCanRun   = useSignal(diagnosticSequence.canExecute$);
 
  return (
    <div>
      <button
        onClick={() => parallelRefresh.execute()}
        disabled={!parallelCanRun || parallelRunning}
      >
        {parallelRunning ? 'Refreshing…' : 'Refresh all streams'}
      </button>
 
      <button
        onClick={() => diagnosticSequence.execute()}
        disabled={!sequenceCanRun || sequenceRunning}
      >
        {sequenceRunning ? 'Running…' : 'Run diagnostics'}
      </button>
    </div>
  );
}

useMemo ensures CompositeCommand instances are created once. The useEffect cleanup disposes them when the component unmounts.


Fluent Command: Signal canExecute Guards

From apps/mvvm-react-integrated/src/components/FluentCommandShowcase.tsx — a registration form where the submit button only enables when every field rule passes:

import { useMemo, useEffect } from 'react';
import { Command } from '@web-loom/mvvm-core';
import { computed, signal } from '@web-loom/signals-core';
import { useSignal } from '../hooks/useSignal';
 
function RegistrationForm() {
  // Local signals for form field values
  const username$ = useMemo(() => signal(''), []);
  const email$    = useMemo(() => signal(''), []);
  const password$ = useMemo(() => signal(''), []);
  const policy$   = useMemo(() => signal(false), []);
 
  // Derived: email must match basic format
  const emailValid$ = useMemo(
    () => computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email$.get())),
    [email$]
  );
 
  // Command with multiple signal guards — all must be truthy to enable
  const registerCommand = useMemo(() => {
    return new Command(async () => {
      await api.register({ email: email$.peek(), password: password$.peek() });
    })
      .observesProperty(username$)     // username must be non-empty
      .observesProperty(password$)     // password must be non-empty
      .observesCanExecute(emailValid$) // email must be valid format
      .observesCanExecute(policy$);    // policy checkbox must be checked
  }, [username$, email$, password$, policy$, emailValid$]);
 
  // Dispose the command on unmount — plain signals need no separate teardown
  useEffect(() => {
    return () => registerCommand.dispose();
  }, [registerCommand]);
 
  const canSubmit    = useSignal(registerCommand.canExecute$);
  const isSubmitting = useSignal(registerCommand.isExecuting$);
 
  return (
    <form>
      <input
        placeholder="Username"
        onChange={e => { username$.set(e.target.value); registerCommand.raiseCanExecuteChanged(); }}
      />
      <input
        type="email"
        placeholder="Email"
        onChange={e => { email$.set(e.target.value); registerCommand.raiseCanExecuteChanged(); }}
      />
      <input
        type="password"
        onChange={e => { password$.set(e.target.value); registerCommand.raiseCanExecuteChanged(); }}
      />
      <label>
        <input type="checkbox" onChange={e => { policy$.set(e.target.checked); registerCommand.raiseCanExecuteChanged(); }} />
        Accept policy
      </label>
      <button
        type="button"
        onClick={() => registerCommand.execute()}
        disabled={!canSubmit || isSubmitting}
      >
        {isSubmitting ? 'Submitting…' : 'Register'}
      </button>
    </form>
  );
}

canExecute$ is a computed() internally, so guards built from signals (emailValid$, policy$, username$, password$) already re-evaluate automatically on change — the raiseCanExecuteChanged() calls above are a defensive, explicit trigger that keeps the button state trivially predictable regardless of guard internals, matching the pattern used in the real component. The button disabled prop is purely reactive — it reads canExecute$ without any manual validation checks in the component.


ViewModel Disposal and React Lifecycle

The ViewModel lifecycle maps directly to the React component lifecycle:

Component mounts
    → useMemo creates ViewModel
    → useEffect subscribes / fetches

Component updates (props change)
    → useMemo with deps re-creates ViewModel if deps changed
    → useEffect cleanup disposes old ViewModel
    → useEffect re-creates subscription / re-fetches

Component unmounts
    → useEffect cleanup fires
    → vm.dispose() disposes registered Commands, runs addSubscription teardowns
    → No memory leak

The critical rule: every ViewModel created inside a component must be disposed in a useEffect cleanup. Failure to do this leaves Commands and manual subscriptions live, and memory growing.

// ✅ Correct
useEffect(() => {
  return () => vm.dispose(); // called on unmount or vm change
}, [vm]);
 
// ❌ Wrong — no cleanup, memory leak
useEffect(() => {
  vm.fetchCommand.execute();
}, []);

Testing React + MVVM Components

With ViewModels injected (not constructed inside components), testing is straightforward.

Testing a Component with a Mocked ViewModel

import { render, screen, act } from '@testing-library/react';
import { signal } from '@web-loom/signals-core';
import { GreenhouseSummary } from './GreenhouseSummary';
 
// Create a minimal mock ViewModel
const mockVm = {
  data$: signal([{ id: '1', name: 'Alpha', location: 'Zone A' }]),
  isLoading$: signal(false),
  error$: signal(null),
  fetchCommand: { execute: vi.fn().mockResolvedValue(undefined), canExecute$: signal(true) },
};
 
it('renders greenhouse names from ViewModel', async () => {
  render(<GreenhouseSummary vm={mockVm} />);
  expect(screen.getByText('Alpha')).toBeInTheDocument();
});
 
it('shows loading state', async () => {
  mockVm.isLoading$.set(true);
  render(<GreenhouseSummary vm={mockVm} />);
  expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
 
it('updates when data changes', async () => {
  render(<GreenhouseSummary vm={mockVm} />);
 
  act(() => {
    mockVm.data$.set([
      { id: '1', name: 'Alpha', location: 'Zone A' },
      { id: '2', name: 'Beta',  location: 'Zone B' },
    ]);
  });
 
  expect(screen.getByText('Beta')).toBeInTheDocument();
});

act() ensures React flushes the state update triggered by .set() before asserting.

Testing Command Execution

it('calls fetchCommand.execute on mount', () => {
  render(<GreenhouseList vm={mockVm} />);
  expect(mockVm.fetchCommand.execute).toHaveBeenCalledOnce();
});
 
it('calls deleteCommand when delete button clicked', async () => {
  mockVm.deleteCommand = { execute: vi.fn().mockResolvedValue(undefined) };
  render(<GreenhouseList vm={mockVm} />);
 
  await userEvent.click(screen.getByRole('button', { name: /delete/i }));
 
  expect(mockVm.deleteCommand.execute).toHaveBeenCalledWith('1');
});

Dos and Don'ts

Do: Use useSignal for Every Signal Read

// ✅ Good — React re-renders when data changes
const items = useSignal(vm.data$);
// ❌ Bad — React never re-renders
const items = vm.data$.peek(); // bypasses React state entirely

Do: Dispose Per-Component ViewModels in useEffect

// ✅ Good
useEffect(() => {
  vm.fetchCommand.execute();
  return () => vm.dispose();
}, [vm]);
// ❌ Bad — ViewModel leaks after unmount
useEffect(() => {
  vm.fetchCommand.execute();
}, []);

Do: Create Per-Component ViewModels with useMemo

// ✅ Good — created once per mount, not on every render
const vm = useMemo(() => new TaskViewModel(new TaskModel(id)), [id]);
// ❌ Bad — new ViewModel on every render, all subscriptions reset
function TaskDetail() {
  const vm = new TaskViewModel(new TaskModel(id)); // runs every render
}

Do: Pass ViewModels as Props for Testability

// ✅ Good — easy to inject mock in tests
function GreenhouseCard({ vm }: { vm: GreenHouseViewModel }) {
  const data = useSignal(vm.data$);
  return <div>{data.length} greenhouses</div>;
}
// ❌ Bad — impossible to test without the real module
function GreenhouseCard() {
  const data = useSignal(greenHouseViewModel.data$); // hardcoded singleton
}

Don't: Call execute() Inside the Render Function

// ❌ Bad — fires on every render
function BadComponent() {
  vm.fetchCommand.execute(); // called during every render pass
  return <div>...</div>;
}
// ✅ Good — fires once on mount
useEffect(() => {
  vm.fetchCommand.execute();
}, []);

Don't: Store Signal Data in Both React State and ViewModel

// ❌ Bad — two sources of truth that can diverge
const [items, setItems] = useState([]);
useEffect(() => {
  return vm.data$.subscribe(data => {
    setItems(data); // duplicates state that already lives in vm.data$
    doSomethingWith(data);
  });
}, []);
// ✅ Good — single source via useSignal
const items = useSignal(vm.data$);
useEffect(() => {
  if (items.length > 0) doSomethingWith(items);
}, [items]);

Where to Go Next

  • MVVM in Vue — Vue 3 Composition API integration patterns
  • MVVM in Angular — Angular services and signal bridging
  • Models — the data layer that ViewModels read signals from
  • ViewModels — Commands, derived signals, and disposal patterns
  • Signals Core — the signal/computed/effect primitives underlying mvvm-core
Was this helpful?
Web Loom logo
Copyright © Web Loom. All rights reserved.