MVVM in React Native
How React Native components consume the same ViewModels as the web — the only change is swapping HTML elements for native primitives. The useSignal hook and the Command pattern are identical.
MVVM in React Native
React Native is the most direct demonstration of what framework-agnostic architecture actually means in practice. The ViewModels used in the Web Loom Greenhouse web app are imported unchanged into the React Native mobile app. No adapters, no wrappers, no port. The same GreenHouseViewModel, SensorViewModel, and ThresholdAlertViewModel instances that drive the React web components also drive the React Native screens.
The only things that change are the rendering primitives — View instead of div, Text instead of p, FlatList instead of ul — and the subscription hook, which is identical in structure to the web version.
Why It Works Without Modification
ViewModels in @web-loom/mvvm-core are plain TypeScript classes with no DOM or browser imports. They depend on @web-loom/signals-core — a zero-dependency, pure-JavaScript reactive primitive — and nothing else. React Native runs a full JavaScript environment, so the ViewModels simply work, unchanged.
packages/view-models/ ← framework-agnostic TypeScript
GreenHouseViewModel.ts ← imported by both web React and React Native
SensorViewModel.ts
...
apps/mvvm-react/ ← React web: uses HTML elements
apps/mvvm-react-native/ ← React Native: uses native primitives
Both apps share the same ViewModel singletons from @repo/view-models. State fetched in the mobile app reflects the same data the web app would show.
The useSignal Hook
The bridge between @web-loom/signals-core signals and React Native components is the exact same useSignal hook used on the web — useSyncExternalStore:
// apps/mvvm-react-native/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);
}The hook is identical to the one used in apps/mvvm-react — not just structurally similar, the file content is the same. React Native's useSyncExternalStore behaves the same as React DOM's — the rendering target is different, but the hooks API is the same.
A Native Component Consuming a ViewModel
Here is the greenhouse list screen from the React Native app. Compare it line for line with the React web version and the only differences are the native import names:
// apps/mvvm-react-native/src/components/GreenhouseList.tsx
import React, { useEffect, useState } from 'react';
import {
View, Text, FlatList, TextInput,
TouchableOpacity, ScrollView, StyleSheet,
} from 'react-native'; // ← native primitives
import { useSignal } from '../hooks/useSignal';
import { greenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
export const GreenhouseList = () => {
const greenHouses = useSignal(greenHouseViewModel.data$);
const [name, setName] = useState('');
const [editingGreenhouseId, setEditingGreenhouseId] = useState<string | null>(null);
useEffect(() => {
greenHouseViewModel.fetchCommand.execute();
}, []);
const handleSubmit = () => {
if (editingGreenhouseId) {
greenHouseViewModel.updateCommand.execute({
id: editingGreenhouseId,
payload: { id: editingGreenhouseId, name, location: '', size: '', cropType: '' },
});
setEditingGreenhouseId(null);
} else {
greenHouseViewModel.createCommand.execute({ name, location: '', size: '', cropType: '' });
}
setName('');
};
return (
<ScrollView style={{ flex: 1 }}>
<TextInput value={name} onChangeText={setName} placeholder="Greenhouse name" />
<TouchableOpacity onPress={handleSubmit}>
<Text>{editingGreenhouseId ? 'Update' : 'Submit'}</Text>
</TouchableOpacity>
<FlatList
data={greenHouses}
keyExtractor={(item) => item.id ?? ''}
renderItem={({ item }) => (
<View>
<Text>{item.name}</Text>
<TouchableOpacity onPress={() => greenHouseViewModel.deleteCommand.execute(item.id!)}>
<Text>Delete</Text>
</TouchableOpacity>
</View>
)}
/>
</ScrollView>
);
};The ViewModel calls — greenHouseViewModel.fetchCommand.execute(), greenHouseViewModel.createCommand.execute(...), greenHouseViewModel.deleteCommand.execute(...) — are byte-for-byte the same as the web components. The Command pattern doesn't know or care what framework is calling it.
Commands with Native UI
Binding Command state to native loading indicators follows the same pattern as the web:
// Read command state via useSignal
const isExecuting = useSignal(greenHouseViewModel.fetchCommand.isExecuting$);
const canExecute = useSignal(greenHouseViewModel.fetchCommand.canExecute$);
// Bind to native components
<TouchableOpacity
onPress={() => greenHouseViewModel.fetchCommand.execute()}
disabled={!canExecute}
>
<Text>{isExecuting ? 'Loading…' : 'Refresh'}</Text>
</TouchableOpacity>
{isExecuting && <ActivityIndicator />}No loading flags in component state. The Command owns that state; the component subscribes to it.
Dashboard: Combining Multiple ViewModels
The real dashboard screen (apps/mvvm-react-native/src/components/Dashboard.tsx) reads each ViewModel's data$ independently with useSignal and fetches them in parallel — no combination needed for the data itself:
import { useEffect } from 'react';
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 Dashboard = () => {
const greenHouses = useSignal(greenHouseViewModel.data$);
const sensors = useSignal(sensorViewModel.data$);
const sensorReadings = useSignal(sensorReadingViewModel.data$);
const thresholdAlerts = useSignal(thresholdAlertViewModel.data$);
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();
}, []);
// ... render cards, passing greenHouses/sensors/etc. as props
};If you do want a single combined loading flag, compose a computed() over each isLoading$ (the same pattern used in the Lit dashboard):
import { computed } from '@web-loom/signals-core';
const anyLoading$ = computed(
() =>
greenHouseViewModel.isLoading$.get() ||
sensorViewModel.isLoading$.get() ||
sensorReadingViewModel.isLoading$.get() ||
thresholdAlertViewModel.isLoading$.get(),
);
// In the component:
const isLoading = useSignal(anyLoading$);This composition happens at the ViewModel/signal level, not at the React Native level. If you later add a web version of this dashboard, the same computed() moves over unchanged.
Disposal
React Native components unmount the same way React web components do. Dispose the ViewModel in the useEffect cleanup:
useEffect(() => {
const unsubscribe = vm.data$.subscribe(setData);
vm.fetchCommand.execute();
return () => {
unsubscribe();
vm.dispose(); // disposes registered Commands, runs addSubscription teardowns
};
}, []);If the ViewModel is a shared singleton (as in the Greenhouse app where all screens share one instance), skip vm.dispose() on individual screen unmount — only dispose when the app fully tears down. For screen-scoped ViewModels, always dispose in the cleanup.
What Changes vs Web React
- Rendering — Web React uses
div,span,ul,button; React Native usesView,Text,FlatList,TouchableOpacity - Styling — Web React uses CSS classes or CSS-in-JS; React Native uses
StyleSheet.create() - Subscription hook —
useSignal(useSyncExternalStore) — identical in both - ViewModel — same class, same import in both
- Commands —
.execute(),isExecuting$— identical in both - Disposal —
useEffectcleanup — identical in both
The ViewModel layer — the 80% — is genuinely shared. Only the thin View layer differs.
Testing
ViewModels have no React Native imports, so the test approach is identical to the web:
import { describe, it, expect, vi } from 'vitest';
import type { WritableSignal } from '@web-loom/signals-core';
import { TaskListViewModel } from '../TaskListViewModel';
it('pendingCount$ reflects undone tasks', () => {
const vm = new TaskListViewModel(mockModel);
(mockModel.data$ as WritableSignal<any>).set([
{ id: '1', done: false },
{ id: '2', done: true },
]);
expect(vm.pendingCount$.peek()).toBe(1);
vm.dispose();
});No Expo test environment, no React Native test renderer, no platform mocking. The ViewModel is a plain TypeScript class.
Summary
React Native integration requires no special adapter layer. The pattern is:
- Import the same ViewModel instance used by the web app
- Subscribe using
useSignal(useSyncExternalStore) — identical to the web hook - Render with native primitives (
View,Text,FlatList) instead of HTML elements - Call Commands on user interaction —
vm.fetchCommand.execute(),vm.createCommand.execute(payload) - Dispose in
useEffectcleanup (or on app teardown for singletons)
The ViewModel doesn't know it's being consumed by a mobile app. That's the point.