MVVM in React
How React's rendering model works, why RxJS observables 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 and RxJS operate on fundamentally different mental models. Understanding that gap is what makes MVVM feel natural in React rather than awkward. This page explains React's rendering system, why observables 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
useStateoruseReducerstate 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 an RxJS BehaviorSubject, 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 BehaviorSubject from @web-loom/mvvm-core holds a value and pushes updates to subscribers. React has no idea this value exists:
// React is completely unaware of this update
const subject = new BehaviorSubject(0);
subject.next(1); // pushes to RxJS subscribers, NOT to ReactIf a component reads subject.getValue() directly, it will show the initial value forever — React never knows it needs to re-render.
Bridging Observables to React
The fix is to connect observable emissions to React state. There are two approaches.
Approach 1: useObservable (useState + useEffect)
The pattern used throughout the Web Loom React apps:
// apps/mvvm-react/src/hooks/useObservable.ts
import { useState, useEffect } from 'react';
import { Observable } from 'rxjs';
export function useObservable<T>(observable: Observable<T>, initialValue: T): T {
const [value, setValue] = useState<T>(initialValue);
useEffect(() => {
const subscription = observable.subscribe(setValue);
return () => subscription.unsubscribe();
}, [observable]);
return value;
}How it works:
useStateholds the latest emitted value as React stateuseEffectsubscribes to the observable when the component mounts- The subscription calls
setValueon every emission — this triggers a React re-render - The cleanup function (
unsubscribe) runs when the component unmounts or the observable reference changes initialValueis returned immediately on the first render before the first emission
Usage:
function GreenhouseList() {
const greenHouses = useObservable(vm.data$, []);
const isLoading = useObservable(vm.isLoading$, true);
const error = useObservable(vm.error$, null);
// greenHouses, isLoading, error are now plain React state —
// they trigger re-renders automatically when the ViewModel updates them.
}Approach 2: useSyncExternalStore (React 18+ Concurrent Mode)
React 18 introduced useSyncExternalStore specifically for subscribing to external data sources. It is safer in concurrent mode because it prevents tearing — a scenario where different parts of the UI read different snapshots of the same store during a single render pass.
import { useSyncExternalStore } from 'react';
import { Observable, BehaviorSubject } from 'rxjs';
export function useObservableSync<T>(subject: BehaviorSubject<T>): T {
return useSyncExternalStore(
(onStoreChange) => {
const sub = subject.subscribe(onStoreChange);
return () => sub.unsubscribe();
},
() => subject.getValue(), // read current value (client)
() => subject.getValue(), // read current value (server)
);
}- The first argument is a
subscribefunction — React calls it to get notified when the store changes - The second argument is a
getSnapshotfunction — React calls it synchronously to read the current value - React guarantees that all components reading the same store see a consistent snapshot within a single render
useSyncExternalStore is the officially recommended approach for external stores in React 18+. The useObservable pattern with useState + useEffect works well in practice but can theoretically produce inconsistent reads during concurrent rendering. For BehaviorSubjects (which hold a value synchronously accessible via .getValue()), useSyncExternalStore is a clean fit.
Both approaches are valid. Web Loom's apps use useObservable (simpler, works for Observable not just BehaviorSubject). For new projects targeting React 18+ concurrent features, prefer useSyncExternalStore.
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 { greenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
function GreenhouseSummary() {
const count = useObservable(
greenHouseViewModel.data$.pipe(map(list => list?.length ?? 0)),
0
);
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 = useObservable(vm.data$, null);
const isLoading = useObservable(vm.isLoading$, true);
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 observable 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 { useObservable } from '../hooks/useObservable';
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 = useObservable(authViewModel.isAuthenticated$, false);
const isLoading = useObservable(authViewModel.isLoading$, false);
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 observables that map directly to common UI states:
isExecuting$→ loading spinner / button textcanExecute$→ buttondisabledpropexecuteError$→ error message display
function SaveButton({ vm }: { vm: DocumentViewModel }) {
const canSave = useObservable(vm.saveCommand.canExecute$, false);
const isSaving = useObservable(vm.saveCommand.isExecuting$, false);
const saveError = useObservable(vm.saveCommand.executeError$, null);
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, type GreenhouseData } from '@repo/view-models/GreenHouseViewModel';
import { useObservable } from '../hooks/useObservable';
export function GreenhouseList() {
// Subscribe to ViewModel observables
const greenHouses = useObservable(greenHouseViewModel.data$, [] as GreenhouseData[]);
const isLoading = useObservable(greenHouseViewModel.isLoading$, false);
// 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/catcharound API calls - No manual
isLoadingstate 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 { useObservable } from '../hooks/useObservable';
const Dashboard = () => {
const greenHouses = useObservable(greenHouseViewModel.data$, []);
const sensors = useObservable(sensorViewModel.data$, []);
const sensorReadings = useObservable(sensorReadingViewModel.data$, []);
const alerts = useObservable(thresholdAlertViewModel.data$, []);
const isLoading =
useObservable(greenHouseViewModel.isLoading$, true) ||
useObservable(sensorViewModel.isLoading$, true) ||
useObservable(sensorReadingViewModel.isLoading$, true) ||
useObservable(thresholdAlertViewModel.isLoading$, true);
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$ observables 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 { useObservable } from '../hooks/useObservable';
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 = useObservable(parallelRefresh.isExecuting$, false);
const parallelCanRun = useObservable(parallelRefresh.canExecute$, true);
const sequenceRunning = useObservable(diagnosticSequence.isExecuting$, false);
const sequenceCanRun = useObservable(diagnosticSequence.canExecute$, true);
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: Observable 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, useState } from 'react';
import { Command } from '@web-loom/mvvm-core';
import { BehaviorSubject } from 'rxjs';
import { map } from 'rxjs/operators';
import { useObservable } from '../hooks/useObservable';
function RegistrationForm() {
// Local BehaviorSubjects for form field values
const username$ = useMemo(() => new BehaviorSubject(''), []);
const email$ = useMemo(() => new BehaviorSubject(''), []);
const password$ = useMemo(() => new BehaviorSubject(''), []);
const policy$ = useMemo(() => new BehaviorSubject(false), []);
// Derived: email must match basic format
const emailValid$ = useMemo(
() => email$.pipe(map(v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v))),
[email$]
);
// Command with multiple observable guards — all must be truthy to enable
const registerCommand = useMemo(() => {
return new Command(async () => {
await api.register({ email: email$.getValue(), password: password$.getValue() });
})
.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 everything on unmount
useEffect(() => {
return () => {
registerCommand.dispose();
username$.complete();
email$.complete();
password$.complete();
policy$.complete();
};
}, [registerCommand, username$, email$, password$, policy$]);
const canSubmit = useObservable(registerCommand.canExecute$, false);
const isSubmitting = useObservable(registerCommand.isExecuting$, false);
return (
<form>
<input
placeholder="Username"
onChange={e => { username$.next(e.target.value); registerCommand.raiseCanExecuteChanged(); }}
/>
<input
type="email"
placeholder="Email"
onChange={e => { email$.next(e.target.value); registerCommand.raiseCanExecuteChanged(); }}
/>
<input
type="password"
onChange={e => { password$.next(e.target.value); registerCommand.raiseCanExecuteChanged(); }}
/>
<label>
<input type="checkbox" onChange={e => { policy$.next(e.target.checked); registerCommand.raiseCanExecuteChanged(); }} />
Accept policy
</label>
<button
type="button"
onClick={() => registerCommand.execute()}
disabled={!canSubmit || isSubmitting}
>
{isSubmitting ? 'Submitting…' : 'Register'}
</button>
</form>
);
}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() completes all BehaviorSubjects
→ All subscriptions closed → no memory leak
The critical rule: every ViewModel created inside a component must be disposed in a useEffect cleanup. Failure to do this leaves BehaviorSubjects open, subscriptions dangling, 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 { BehaviorSubject } from 'rxjs';
import { GreenhouseSummary } from './GreenhouseSummary';
// Create a minimal mock ViewModel
const mockVm = {
data$: new BehaviorSubject([{ id: '1', name: 'Alpha', location: 'Zone A' }]),
isLoading$: new BehaviorSubject(false),
error$: new BehaviorSubject(null),
fetchCommand: { execute: vi.fn().mockResolvedValue(undefined), canExecute$: new BehaviorSubject(true) },
};
it('renders greenhouse names from ViewModel', async () => {
render(<GreenhouseSummary vm={mockVm} />);
expect(screen.getByText('Alpha')).toBeInTheDocument();
});
it('shows loading state', async () => {
mockVm.isLoading$.next(true);
render(<GreenhouseSummary vm={mockVm} />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('updates when data changes', async () => {
render(<GreenhouseSummary vm={mockVm} />);
act(() => {
mockVm.data$.next([
{ 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 .next() 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 useObservable for Every Observable Subscription
// ✅ Good — React re-renders when data changes
const items = useObservable(vm.data$, []);// ❌ Bad — React never re-renders
const items = vm.data$.getValue(); // bypasses React state entirelyDo: 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 = useObservable(vm.data$, []);
return <div>{data.length} greenhouses</div>;
}// ❌ Bad — impossible to test without the real module
function GreenhouseCard() {
const data = useObservable(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 Observable Data in Both React State and ViewModel
// ❌ Bad — two sources of truth that can diverge
const [items, setItems] = useState([]);
useEffect(() => {
vm.data$.subscribe(data => {
setItems(data); // duplicates state that already lives in vm.data$
doSomethingWith(data);
});
}, []);// ✅ Good — single source via useObservable
const items = useObservable(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 async pipe integration
- Models — the data layer that ViewModels subscribe to
- ViewModels — Commands, derived observables, and disposal patterns
- Signals Core — an alternative to RxJS for simpler reactive state