Web Loom logo
MVVM CoreMVVM in React Native

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 uses View, Text, FlatList, TouchableOpacity
  • Styling — Web React uses CSS classes or CSS-in-JS; React Native uses StyleSheet.create()
  • Subscription hookuseSignal (useSyncExternalStore) — identical in both
  • ViewModel — same class, same import in both
  • Commands.execute(), isExecuting$ — identical in both
  • DisposaluseEffect cleanup — 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:

  1. Import the same ViewModel instance used by the web app
  2. Subscribe using useSignal (useSyncExternalStore) — identical to the web hook
  3. Render with native primitives (View, Text, FlatList) instead of HTML elements
  4. Call Commands on user interaction — vm.fetchCommand.execute(), vm.createCommand.execute(payload)
  5. Dispose in useEffect cleanup (or on app teardown for singletons)

The ViewModel doesn't know it's being consumed by a mobile app. That's the point.

Was this helpful?
Web Loom logo
Copyright © Web Loom. All rights reserved.