Chapter 8: React Implementation with Hooks
In the previous chapters, we've built a solid foundation: framework-agnostic Models that encapsulate domain logic, ViewModels that manage presentation state, and an understanding of the View layer contract. Now it's time to see how all these pieces come together in a real framework.
React is an excellent starting point for our framework implementations because its functional component model and hooks API map naturally to the observable patterns we've established. In this chapter, we'll explore how to build React components that consume ViewModels, manage subscriptions with hooks, and maintain the clean separation of concerns that makes MVVM powerful.
We'll use the GreenWatch greenhouse monitoring system as our example, showing real implementations from apps/mvvm-react/ in the Web Loom monorepo. By the end of this chapter, you'll understand how to build React applications with MVVM architecture—and you'll see that the same ViewModels work identically in Vue, Angular, Lit, and vanilla JavaScript (which we'll cover in the following chapters).
8.1 The React-MVVM Integration Challenge
React components need to:
- Read ViewModel signals
- Trigger re-renders when signal values change
- Clean up subscriptions when components unmount
- Execute ViewModel commands in response to user actions
The challenge is that React's state management is based on useState and useReducer, while our ViewModels expose @web-loom/signals-core signals. We need a bridge between these two worlds.
The solution is a custom hook: useSignal.
8.2 The useSignal Hook: Bridging Signals and React State
The useSignal hook is the cornerstone of React-MVVM integration. It reads a @web-loom/signals-core signal, converts its value into a React-tracked read, and handles cleanup automatically via useSyncExternalStore. Here's the complete implementation from the GreenWatch React app:
// 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);
}Let's break down what's happening here:
1. Snapshot Reads: useSyncExternalStore calls sig.get to read the current value synchronously — both for the client snapshot and the (identical, since this isn't SSR-hydration-sensitive) server snapshot. No initialValue parameter, no first-render flash of a placeholder.
2. Subscription: useSyncExternalStore calls sig.subscribe internally when the component mounts. sig.subscribe already has exactly the shape React expects — (callback) => unsubscribeFunction — so it's passed straight through with no wrapper.
3. Cleanup: React calls the unsubscribe function sig.subscribe returned when the component unmounts. This prevents memory leaks without any explicit cleanup code in the hook itself.
4. Consistency: useSyncExternalStore guarantees that all components reading the same signal see a consistent value within a single render pass — this is the API React 18+ recommends for exactly this kind of external-store integration.
This simple hook is all you need to integrate Web Loom signals with React. The signal's own .subscribe/.get shape is what makes the hook this small — there's no adapter logic to write.
8.3 Building the Dashboard: Multi-ViewModel Coordination
The GreenWatch Dashboard is a perfect example of how React components consume multiple ViewModels. It displays greenhouses, sensors, sensor readings, and threshold alerts—all managed by separate ViewModels but coordinated in a single component.
Here's the complete Dashboard implementation:
// 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;8.3.1 Pattern Analysis: What Makes This Work
Let's examine the key patterns in this Dashboard component:
1. Direct ViewModel Imports
import { greenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
import { sensorViewModel } from '@repo/view-models/SensorViewModel';The ViewModels are imported directly as singleton instances. This works because the Dashboard shows global application state—all components see the same greenhouses, sensors, and alerts. We'll explore when to use singletons vs. transient instances later in this chapter.
2. Signal Reads with useSignal
const greenHouses = useSignal(greenHouseViewModel.data$);
const isLoadingGreenHouses = useSignal(greenHouseViewModel.isLoading$);Each useSignal call creates a subscription that:
- Reads the current value synchronously on first render (no placeholder needed)
- Updates React state when the signal changes
- Automatically unsubscribes when the component unmounts
Notice we subscribe to both data$ and isLoading$ from each ViewModel. This gives us fine-grained control over what we display—we can show loading spinners while data is being fetched.
3. Command Execution in useEffect
useEffect(() => {
const fetchData = async () => {
try {
await greenHouseViewModel.fetchCommand.execute();
await sensorViewModel.fetchCommand.execute();
// ... more commands
} catch (error) {
console.error('Error fetching data:', error);
}
};
fetchData();
}, []); // Empty dependency array = run once on mountThe empty dependency array [] ensures this effect runs exactly once when the component mounts. We execute all fetch commands in parallel (they're all async), and handle errors gracefully.
Why not call commands directly in the component body? Because that would execute on every render, causing infinite loops. useEffect with an empty dependency array is the React idiom for "run once on mount."
4. Derived State with Simple Logic
const isLoading =
isLoadingGreenHouses ||
isLoadingSensors ||
isLoadingSensorReadings ||
isLoadingThresholdAlerts;We combine loading states from multiple ViewModels using simple boolean logic. This is fine for straightforward cases. For more complex derived state, you'd move this logic into a ViewModel (we'll see examples later).
5. Conditional Rendering
{isLoading && <p>Loading dashboard data...</p>}
{!isLoading && (
<>
{/* Dashboard content */}
</>
)}React's conditional rendering handles the loading state. When any ViewModel is loading, we show a loading message. Once all data is loaded, we render the dashboard cards.
8.4 List Views: Sensors and Greenhouses
Let's look at simpler components that display lists. The SensorList component shows all sensors in the system:
// apps/mvvm-react/src/components/SensorList.tsx
import { useEffect } from 'react';
import { sensorViewModel } from '@repo/view-models/SensorViewModel';
import { useSignal } from '../hooks/useSignal';
import { Link } from 'react-router-dom';
import BackArrow from '../assets/back-arrow.svg';
export function SensorList() {
const sensors = useSignal(sensorViewModel.data$);
useEffect(() => {
const fetchData = async () => {
await sensorViewModel.fetchCommand.execute();
};
fetchData();
}, []);
return (
<>
<Link to="/" className="back-button">
<img src={BackArrow} alt="Back to dashboard" style={{ width: '36px', height: '36px' }} />
</Link>
<div className="card">
<h1 className="card-title">Sensors</h1>
{sensors && sensors.length > 0 ? (
<ul className="card-content list">
{sensors.map((sensor) => (
<li key={sensor.id} className="list-item">
{sensor.greenhouse.name} {sensor.type} (Status: {sensor.status})
</li>
))}
</ul>
) : (
<p>No sensors found or still loading...</p>
)}
</div>
</>
);
}This component demonstrates the minimal pattern for consuming a ViewModel:
- Read the data signal:
useSignal(sensorViewModel.data$) - Fetch on mount:
useEffectwithfetchCommand.execute() - Render the data: Map over the array and render list items
Notice what's NOT here:
- No API calls
- No state management beyond what
useSignalprovides - No business logic (the sensor status is already computed by the ViewModel)
- No manual subscription cleanup (handled by
useSignal)
This is the "dumb view" philosophy in action. The component is purely presentational.
8.5 CRUD Operations: The Greenhouse List
The GreenhouseList component is more complex—it handles Create, Read, Update, and Delete operations. This is where we see the full power of the Command pattern:
// apps/mvvm-react/src/components/GreenhouseList.tsx (excerpt)
import { useEffect } from 'react';
import { greenHouseViewModel, type GreenhouseData } from '@repo/view-models/GreenHouseViewModel';
import { useSignal } from '../hooks/useSignal';
export function GreenhouseList() {
const greenHouses = useSignal(greenHouseViewModel.data$);
useEffect(() => {
const fetchData = async () => {
try {
await greenHouseViewModel.fetchCommand.execute();
} catch (error) {
console.error('Error fetching greenhouses:', error);
}
};
fetchData();
}, []);
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
const formData = new FormData(event.target as HTMLFormElement);
const name = formData.get('name') as string;
const location = formData.get('location') as string;
const size = formData.get('size') as string;
const cropType = formData.get('cropType') as string;
const data = { name, location, size, cropType };
const existingGreenhouse = greenHouses?.find((gh) => gh.name === name);
if (existingGreenhouse) {
// Update existing greenhouse
greenHouseViewModel.updateCommand.execute({
id: existingGreenhouse.id || '',
payload: {
...existingGreenhouse,
name,
location,
size,
cropType,
},
});
return;
}
// Create new greenhouse
greenHouseViewModel.createCommand.execute(data);
};
const handleDelete = (id?: string) => {
if (!id) {
console.error('No ID provided for deletion');
return;
}
greenHouseViewModel.deleteCommand.execute(id);
};
return (
<section className="flex-container flex-row">
<form className="form-container" onSubmit={handleSubmit}>
{/* Form fields... */}
<button type="submit" className="button">
Submit
</button>
</form>
<div className="card">
<h1 className="card-title">Greenhouses</h1>
{greenHouses && greenHouses.length > 0 ? (
<ul className="card-content list">
{greenHouses.map((gh) => (
<li key={gh.id} className="list-item">
<span>{gh.name}</span>
<div className="button-group">
<button
className="button-tiny button-tiny-delete"
onClick={() => handleDelete(gh.id)}
>
Delete
</button>
</div>
</li>
))}
</ul>
) : (
<p>No greenhouses found or still loading...</p>
)}
</div>
</section>
);
}8.5.1 Command Pattern in Action
The CRUD operations demonstrate how Commands simplify state management:
Create Operation:
greenHouseViewModel.createCommand.execute(data);That's it. No need to:
- Make an API call manually
- Update local state
- Handle loading states
- Handle errors
- Refresh the list
The ViewModel's createCommand handles all of that. When the command completes, the ViewModel's data$ signal updates to the new list, and React automatically re-renders.
Update Operation:
greenHouseViewModel.updateCommand.execute({
id: existingGreenhouse.id,
payload: { name, location, size, cropType },
});Same pattern. The command encapsulates the entire update flow.
Delete Operation:
greenHouseViewModel.deleteCommand.execute(id);Again, one line. The ViewModel handles the API call, updates its internal state, and emits the new data.
Why this is powerful:
- Consistency: All CRUD operations follow the same pattern
- Testability: You can test the ViewModel's commands without rendering components
- Reusability: The same commands work in Vue, Angular, Lit, and vanilla JS
- Error Handling: Errors are captured in the ViewModel's
error$signal - Loading States: The ViewModel's
isLoading$signal tracks command execution
The View layer is just wiring—it captures user input and calls commands. All the complexity lives in the ViewModel.
8.6 Understanding the ViewModels
Before we go further, let's examine the ViewModels that power these React components. Remember, these are the same ViewModels used in Vue, Angular, Lit, and vanilla JavaScript—they're completely framework-agnostic.
8.6.1 SensorViewModel
// 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);
}
}
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 listisLoading$: ReadonlySignal<boolean>- Loading stateerror$: ReadonlySignal<any>- Error statefetchCommand- Fetch sensors from APIcreateCommand- Create a new sensorupdateCommand- Update a sensordeleteCommand- Delete a sensor
The ViewModel is instantiated once as a singleton and exported. All React components import and use this same instance.
8.6.2 GreenHouseViewModel
// packages/view-models/src/GreenHouseViewModel.ts
import { createReactiveViewModel, type ViewModelFactoryConfig } from '@web-loom/mvvm-core';
import { greenHouseConfig } from '@repo/models';
import { type GreenhouseListData, GreenhouseListSchema, type GreenhouseData } from '@repo/models';
type TConfig = ViewModelFactoryConfig<GreenhouseListData, typeof GreenhouseListSchema>;
const config: TConfig = {
modelConfig: greenHouseConfig,
schema: GreenhouseListSchema,
};
export const greenHouseViewModel = createReactiveViewModel(config);
export type { GreenhouseListData, GreenhouseData };This ViewModel uses a factory pattern (createReactiveViewModel) instead of manual instantiation. The factory creates both the Model and ViewModel from a configuration object. This approach reduces boilerplate when you have many similar ViewModels.
The result is the same: a ViewModel that exposes signals and commands for CRUD operations.
8.7 Advanced Patterns: Custom Hooks for ViewModels
As your application grows, you might want to create custom hooks that encapsulate common ViewModel patterns. Here are some examples:
8.7.1 useViewModel Hook
// src/hooks/useViewModel.ts
import { useEffect } from 'react';
import { useSignal } from './useSignal';
import type { RestfulApiViewModel } from '@web-loom/mvvm-core';
export function useViewModel<TData>(
viewModel: RestfulApiViewModel<TData, any>
) {
const data = useSignal(viewModel.data$);
const isLoading = useSignal(viewModel.isLoading$);
const error = useSignal(viewModel.error$);
useEffect(() => {
viewModel.fetchCommand.execute();
}, [viewModel]);
return { data, isLoading, error, viewModel };
}Usage:
function SensorList() {
const { data: sensors, isLoading, error } = useViewModel(sensorViewModel);
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return (
<ul>
{sensors?.map(sensor => (
<li key={sensor.id}>{sensor.name}</li>
))}
</ul>
);
}This hook bundles the common pattern of reading data, loading, and error signals, plus fetching on mount.
8.7.2 useCommand Hook
// src/hooks/useCommand.ts
import { useState, useCallback } from 'react';
import type { ICommand } from '@web-loom/mvvm-core';
export function useCommand<TParam, TResult>(
command: ICommand<TParam, TResult>
) {
const [isExecuting, setIsExecuting] = useState(false);
const [error, setError] = useState<Error | null>(null);
const execute = useCallback(
async (param: TParam) => {
setIsExecuting(true);
setError(null);
try {
const result = await command.execute(param);
return result;
} catch (err) {
setError(err as Error);
throw err;
} finally {
setIsExecuting(false);
}
},
[command]
);
return { execute, isExecuting, error };
}Usage:
function GreenhouseForm() {
const { execute: createGreenhouse, isExecuting, error } = useCommand(
greenHouseViewModel.createCommand
);
const handleSubmit = async (data: GreenhouseData) => {
try {
await createGreenhouse(data);
alert('Greenhouse created!');
} catch (err) {
// Error is already captured in the hook
}
};
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
<button type="submit" disabled={isExecuting}>
{isExecuting ? 'Creating...' : 'Create Greenhouse'}
</button>
{error && <p className="error">{error.message}</p>}
</form>
);
}This hook provides local loading and error state for individual command executions, useful when you want per-button feedback.
8.8 Lifecycle Management and Cleanup
One of React's strengths is its clear component lifecycle. With hooks, lifecycle management is straightforward:
8.8.1 Automatic Cleanup with useSignal
The useSignal hook handles subscription cleanup automatically via useSyncExternalStore:
export function useSignal<T>(sig: ReadonlySignal<T>): T {
return useSyncExternalStore(sig.subscribe, sig.get, sig.get);
}When the component unmounts, React calls the unsubscribe function that sig.subscribe returned. You don't need to manually manage subscriptions in your components.
8.8.2 When to Dispose ViewModels
If you're using singleton ViewModels (like sensorViewModel), you typically don't dispose them—they live for the entire application lifetime.
But if you create transient ViewModel instances, you must dispose them:
function SensorDetail({ sensorId }: { sensorId: string }) {
// Create a ViewModel instance for this specific sensor
const [viewModel] = useState(() => new SensorDetailViewModel(sensorId));
// Dispose when component unmounts
useEffect(() => {
return () => viewModel.dispose();
}, [viewModel]);
const sensor = useSignal(viewModel.sensor$);
return <div>{sensor?.name}</div>;
}The dispose() method:
- Disposes all registered commands
- Runs every teardown registered via
addSubscription() - Prevents memory leaks
Rule of thumb: If you create it, dispose it. If you import it (singleton), don't dispose it.
8.9 Error Handling and Loading States
React components should always handle loading and error states from ViewModels. Here's a comprehensive pattern:
function SensorList() {
const sensors = useSignal(sensorViewModel.data$);
const isLoading = useSignal(sensorViewModel.isLoading$);
const error = useSignal(sensorViewModel.error$);
useEffect(() => {
sensorViewModel.fetchCommand.execute();
}, []);
// Loading state
if (isLoading && !sensors) {
return (
<div className="loading-container">
<LoadingSpinner />
<p>Loading sensors...</p>
</div>
);
}
// Error state
if (error) {
return (
<div className="error-container">
<ErrorIcon />
<p>Failed to load sensors: {error.message}</p>
<button onClick={() => sensorViewModel.fetchCommand.execute()}>
Retry
</button>
</div>
);
}
// Empty state
if (!sensors || sensors.length === 0) {
return (
<div className="empty-container">
<EmptyIcon />
<p>No sensors found</p>
<button onClick={() => navigateToCreateSensor()}>
Add Your First Sensor
</button>
</div>
);
}
// Success state
return (
<ul className="sensor-list">
{sensors.map(sensor => (
<li key={sensor.id}>
<SensorCard sensor={sensor} />
</li>
))}
</ul>
);
}This pattern handles four states:
- Loading: Show spinner while data is being fetched
- Error: Show error message with retry button
- Empty: Show empty state with call-to-action
- Success: Show the data
Notice the condition isLoading && !sensors. This prevents showing the loading spinner when refetching data—we already have data to display, so we show it while the refresh happens in the background.
8.10 React-Specific Considerations
While the MVVM pattern is framework-agnostic, React has some specific considerations:
8.10.1 Avoiding Stale Closures
React hooks can capture stale values in closures. Be careful when using ViewModel values in callbacks:
Problem:
function SensorCard({ sensor }) {
const sensors = useSignal(sensorViewModel.data$);
const handleDelete = () => {
// This might use a stale 'sensors' value
const remaining = sensors.filter(s => s.id !== sensor.id);
console.log(`${remaining.length} sensors remaining`);
};
return <button onClick={handleDelete}>Delete</button>;
}Solution: Use the ViewModel as the source of truth, not local state:
function SensorCard({ sensor }) {
const handleDelete = async () => {
await sensorViewModel.deleteCommand.execute(sensor.id);
// The ViewModel's data$ will emit the updated list
// No need to compute 'remaining' locally
};
return <button onClick={handleDelete}>Delete</button>;
}8.10.2 Memoization for Performance
If you're reading the same signal in multiple places, consider memoizing the derived values:
function Dashboard() {
const sensors = useSignal(sensorViewModel.data$);
// Derive values from the subscribed data, not by subscribing again
const activeSensors = useMemo(
() => sensors.filter(s => s.status === 'active'),
[sensors]
);
const inactiveSensors = useMemo(
() => sensors.filter(s => s.status === 'inactive'),
[sensors]
);
return (
<>
<SensorList sensors={activeSensors} title="Active Sensors" />
<SensorList sensors={inactiveSensors} title="Inactive Sensors" />
</>
);
}Don't call useSignal(sensorViewModel.data$) multiple times. Read it once and derive values with useMemo.
8.10.3 React.StrictMode and Double Rendering
React's StrictMode intentionally double-renders components in development to catch side effects. This can cause commands to execute twice:
useEffect(() => {
sensorViewModel.fetchCommand.execute(); // Might run twice in StrictMode
}, []);This is usually harmless (the second call is a no-op if the first is still in progress), but be aware of it during development. In production builds, StrictMode is disabled and components render once.
8.11 Testing React Components with ViewModels
One of the greatest benefits of MVVM is testability. React components that use ViewModels are easy to test because you can mock the ViewModels:
// __tests__/SensorList.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { signal } from '@web-loom/signals-core';
import { SensorList } from '../SensorList';
import * as viewModels from '@repo/view-models/SensorViewModel';
// Mock the ViewModel module
jest.mock('@repo/view-models/SensorViewModel');
describe('SensorList', () => {
let mockData$: ReturnType<typeof signal<any[]>>;
let mockIsLoading$: ReturnType<typeof signal<boolean>>;
let mockFetchCommand: { execute: jest.Mock };
beforeEach(() => {
mockData$ = signal([]);
mockIsLoading$ = signal(false);
mockFetchCommand = { execute: jest.fn() };
// Mock the ViewModel
(viewModels as any).sensorViewModel = {
data$: mockData$,
isLoading$: mockIsLoading$,
fetchCommand: mockFetchCommand,
};
});
it('displays loading state initially', () => {
mockIsLoading$.set(true);
render(<SensorList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('displays sensors when loaded', async () => {
const sensors = [
{ id: '1', name: 'Sensor 1', type: 'temperature', status: 'active' },
{ id: '2', name: 'Sensor 2', type: 'humidity', status: 'active' },
];
render(<SensorList />);
// Simulate data loading
mockData$.set(sensors);
await waitFor(() => {
expect(screen.getByText(/Sensor 1/)).toBeInTheDocument();
expect(screen.getByText(/Sensor 2/)).toBeInTheDocument();
});
});
it('calls fetchCommand on mount', () => {
render(<SensorList />);
expect(mockFetchCommand.execute).toHaveBeenCalledTimes(1);
});
});This test demonstrates:
- Mocking the ViewModel with plain
signal()instances - Testing loading states
- Testing data rendering
- Verifying command execution
The component logic is simple because all complexity lives in the ViewModel. You can test the ViewModel separately without rendering any components.
8.12 The Same ViewModels, Different Frameworks
Here's the crucial insight: the ViewModels we've used in this chapter are identical to the ViewModels used in Vue, Angular, Lit, and vanilla JavaScript implementations. Let's preview what's coming in the next chapters.
React (This Chapter)
// React: useSignal hook
const sensors = useSignal(sensorViewModel.data$);
useEffect(() => {
sensorViewModel.fetchCommand.execute();
}, []);Vue (Chapter 9)
<!-- Vue: useSignal composable -->
<script setup>
const sensors = useSignal(sensorViewModel.data$);
onMounted(() => {
sensorViewModel.fetchCommand.execute();
});
</script>Angular (Chapter 10)
// Angular: fromLoomSignal bridge
export class SensorListComponent {
public data$ = fromLoomSignal(sensorViewModel.data$);
ngOnInit() {
sensorViewModel.fetchCommand.execute();
}
}<!-- Template -->
<div *ngFor="let sensor of data$()">
{{ sensor.name }}
</div>Lit (Chapter 11)
// Lit: reactive controller
class SensorList extends LitElement {
private sensors = new ViewModelController(this, sensorViewModel.data$);
connectedCallback() {
super.connectedCallback();
sensorViewModel.fetchCommand.execute();
}
render() {
return html`${this.sensors.value.map(s => html`<div>${s.name}</div>`)}`;
}
}Vanilla JS (Chapter 12)
// Vanilla: direct subscription
sensorViewModel.data$.subscribe(sensors => {
renderSensors(sensors);
});
sensorViewModel.fetchCommand.execute();What's the same:
- The ViewModel (
sensorViewModel) - The signals (
data$,isLoading$,error$) - The commands (
fetchCommand.execute()) - The business logic
What's different:
- How subscriptions are managed
- How state triggers re-renders
- Framework-specific syntax
This is the power of MVVM: write the business logic once, adapt the View layer to each framework.
8.13 Best Practices for React-MVVM
Based on the real implementations in the GreenWatch React app, here are the patterns that work:
1. Use useSignal for All Signal Reads
Don't manually subscribe in useEffect. Use the useSignal hook:
❌ Bad:
function SensorList() {
const [sensors, setSensors] = useState([]);
useEffect(() => {
return sensorViewModel.data$.subscribe(setSensors);
}, []);
}✅ Good:
function SensorList() {
const sensors = useSignal(sensorViewModel.data$);
}2. Fetch Data in useEffect, Not in the Component Body
❌ Bad:
function SensorList() {
sensorViewModel.fetchCommand.execute(); // Runs on every render!
const sensors = useSignal(sensorViewModel.data$);
}✅ Good:
function SensorList() {
const sensors = useSignal(sensorViewModel.data$);
useEffect(() => {
sensorViewModel.fetchCommand.execute();
}, []); // Runs once on mount
}3. Handle All Signal States
Always read data$, isLoading$, and error$:
const data = useSignal(viewModel.data$);
const isLoading = useSignal(viewModel.isLoading$);
const error = useSignal(viewModel.error$);
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
if (!data) return <EmptyState />;
return <DataView data={data} />;4. Keep Components Thin
If you find yourself writing complex logic in a component, move it to the ViewModel:
❌ Bad:
function SensorCard({ sensor }) {
const isOverThreshold = sensor.value > sensor.threshold;
const statusColor = isOverThreshold ? 'red' : 'green';
const statusMessage = isOverThreshold
? `Alert: ${sensor.value - sensor.threshold} over threshold`
: 'Normal';
return <div style={{ color: statusColor }}>{statusMessage}</div>;
}✅ Good:
// In ViewModel
class SensorViewModel {
public readonly displayStatus$ = computed(() => {
const sensor = this.data$.get();
return {
color: sensor.value > sensor.threshold ? 'red' : 'green',
message: sensor.value > sensor.threshold
? `Alert: ${sensor.value - sensor.threshold} over threshold`
: 'Normal'
};
});
}
// In Component
function SensorCard() {
const status = useSignal(sensorViewModel.displayStatus$);
return <div style={{ color: status.color }}>{status.message}</div>;
}5. Use Commands for All User Actions
Never make API calls or update state directly in components. Use ViewModel commands:
❌ Bad:
const handleDelete = async () => {
await fetch(`/api/sensors/${sensor.id}`, { method: 'DELETE' });
// Now what? How do we update the list?
};✅ Good:
const handleDelete = () => {
sensorViewModel.deleteCommand.execute(sensor.id);
// ViewModel handles API call and state update
};8.14 Key Takeaways
React's hooks model integrates naturally with MVVM architecture through the useSignal pattern. By bridging @web-loom/signals-core signals with React state via useSyncExternalStore, we achieve clean separation of concerns while maintaining React's declarative programming model.
Core Patterns:
- useSignal Hook: Converts Web Loom signals into React-tracked reads with automatic cleanup
- useEffect for Commands: Execute ViewModel commands on mount with empty dependency array
- Direct ViewModel Imports: Import singleton ViewModels directly for global state
- Command Pattern: Use ViewModel commands for all user actions (CRUD operations)
- State Handling: Always handle loading, error, and empty states from ViewModels
React-Specific Considerations:
- Avoid stale closures by using ViewModels as source of truth
- Memoize derived values with
useMemoinstead of multipleuseSignalreads - Be aware of StrictMode double-rendering in development
- Dispose transient ViewModels in
useEffectcleanup
Testing Benefits:
- Mock ViewModels with plain
signal()instances - Test components without complex setup
- Test ViewModels separately without rendering components
- Verify command execution and state updates
Framework Independence:
The ViewModels used in this chapter (sensorViewModel, greenHouseViewModel, etc.) are identical to those used in Vue, Angular, Lit, and vanilla JavaScript. Only the View layer changes—the business logic remains the same.
In the next chapter, we'll see how Vue's Composition API provides a remarkably similar pattern to React hooks, demonstrating that MVVM patterns transcend framework boundaries.
Next Steps: Chapter 9 will show you how to implement the same GreenWatch application in Vue 3 using the Composition API. You'll see that the ViewModels don't change—only the way we subscribe to them and render the UI. The business logic you write once works everywhere.