React Native

MVVM Architecture in React Native: Is It Worth It?

20250415 0943 React MVVM Architecture Debate Simple Compose 01jrw6akctew39yq610e9qz0cs 1024x683

As React Native apps grow in complexity, maintaining clean and scalable architecture becomes essential. One popular architecture pattern borrowed from native development is MVVM (Model-View-ViewModel). But is it worth using in a JavaScript or TypeScript environment? Having implemented MVVM in multiple production React Native apps, I can share both the benefits and the real-world challenges you’ll encounter.

Let’s break it down—what MVVM means in React Native, how to structure it properly, and when it makes sense to adopt versus when simpler approaches work better.

What Is MVVM?

MVVM stands for:

  • Model – The data and business logic layer (API calls, data transformation, domain entities)
  • View – The UI (React Native components that render the interface)
  • ViewModel – Acts as a bridge between View and Model, managing state, handling user interactions, and exposing computed data to the View

In React Native, the ViewModel is typically implemented using custom hooks, which aligns naturally with React’s functional component paradigm. The hook encapsulates all state management and business logic, while the View component remains purely presentational.

Folder Structure for MVVM in React Native

src/
├── models/           # Data interfaces, DTOs, domain entities
│   ├── User.ts
│   ├── Product.ts
│   └── Order.ts
├── viewmodels/       # Custom hooks that handle state and logic
│   ├── useLoginViewModel.ts
│   ├── useProductListViewModel.ts
│   └── useCartViewModel.ts
├── views/            # Screens & components (presentation layer)
│   ├── screens/
│   │   ├── LoginScreen.tsx
│   │   ├── ProductListScreen.tsx
│   │   └── CartScreen.tsx
│   └── components/
│       ├── ProductCard.tsx
│       └── CartItem.tsx
├── services/         # API clients, storage, utilities
│   ├── api/
│   │   ├── apiClient.ts
│   │   ├── authApi.ts
│   │   └── productApi.ts
│   └── storage/
│       └── secureStorage.ts
├── repositories/     # Data access layer (optional)
│   ├── UserRepository.ts
│   └── ProductRepository.ts
├── navigation/       # Navigation configuration
├── types/            # Shared TypeScript types
└── App.tsx

Complete MVVM Implementation Example

Let’s build a complete product catalog feature using MVVM architecture:

Models Layer

// models/Product.ts
export interface Product {
  id: string;
  name: string;
  description: string;
  price: number;
  imageUrl: string;
  category: string;
  inStock: boolean;
  rating: number;
  reviewCount: number;
}

export interface ProductFilters {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
  inStockOnly?: boolean;
  sortBy?: 'price' | 'rating' | 'name';
  sortOrder?: 'asc' | 'desc';
}

export interface PaginatedResponse<T> {
  data: T[];
  page: number;
  totalPages: number;
  totalItems: number;
  hasMore: boolean;
}

// models/CartItem.ts
export interface CartItem {
  product: Product;
  quantity: number;
}

export interface Cart {
  items: CartItem[];
  subtotal: number;
  tax: number;
  total: number;
}

// models/User.ts
export interface User {
  id: string;
  email: string;
  name: string;
  avatarUrl?: string;
}

export interface LoginPayload {
  email: string;
  password: string;
}

export interface AuthResponse {
  user: User;
  accessToken: string;
  refreshToken: string;
}

Services Layer

// services/api/apiClient.ts
import axios, { AxiosInstance, AxiosError } from 'axios';
import { getSecureValue, setSecureValue } from '../storage/secureStorage';

class ApiClient {
  private client: AxiosInstance;
  private isRefreshing = false;
  private refreshSubscribers: ((token: string) => void)[] = [];

  constructor() {
    this.client = axios.create({
      baseURL: 'https://api.example.com',
      timeout: 10000,
      headers: {
        'Content-Type': 'application/json',
      },
    });

    this.setupInterceptors();
  }

  private setupInterceptors() {
    // Request interceptor - add auth token
    this.client.interceptors.request.use(
      async (config) => {
        const token = await getSecureValue('accessToken');
        if (token) {
          config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
      },
      (error) => Promise.reject(error)
    );

    // Response interceptor - handle token refresh
    this.client.interceptors.response.use(
      (response) => response,
      async (error: AxiosError) => {
        const originalRequest = error.config;
        
        if (error.response?.status === 401 && originalRequest) {
          if (!this.isRefreshing) {
            this.isRefreshing = true;
            try {
              const newToken = await this.refreshToken();
              this.onTokenRefreshed(newToken);
              originalRequest.headers.Authorization = `Bearer ${newToken}`;
              return this.client(originalRequest);
            } catch (refreshError) {
              // Handle logout on refresh failure
              throw refreshError;
            } finally {
              this.isRefreshing = false;
            }
          }
          
          return new Promise((resolve) => {
            this.refreshSubscribers.push((token: string) => {
              originalRequest.headers.Authorization = `Bearer ${token}`;
              resolve(this.client(originalRequest));
            });
          });
        }
        
        return Promise.reject(error);
      }
    );
  }

  private async refreshToken(): Promise<string> {
    const refreshToken = await getSecureValue('refreshToken');
    const response = await axios.post('https://api.example.com/auth/refresh', {
      refreshToken,
    });
    const { accessToken } = response.data;
    await setSecureValue('accessToken', accessToken);
    return accessToken;
  }

  private onTokenRefreshed(token: string) {
    this.refreshSubscribers.forEach((callback) => callback(token));
    this.refreshSubscribers = [];
  }

  get = <T>(url: string, params?: object) => 
    this.client.get<T>(url, { params });
  
  post = <T>(url: string, data?: object) => 
    this.client.post<T>(url, data);
  
  put = <T>(url: string, data?: object) => 
    this.client.put<T>(url, data);
  
  delete = <T>(url: string) => 
    this.client.delete<T>(url);
}

export const apiClient = new ApiClient();

// services/api/productApi.ts
import { apiClient } from './apiClient';
import { Product, ProductFilters, PaginatedResponse } from '../../models/Product';

export const productApi = {
  getProducts: async (
    page: number = 1,
    filters?: ProductFilters
  ): Promise<PaginatedResponse<Product>> => {
    const response = await apiClient.get<PaginatedResponse<Product>>('/products', {
      page,
      ...filters,
    });
    return response.data;
  },

  getProduct: async (id: string): Promise<Product> => {
    const response = await apiClient.get<Product>(`/products/${id}`);
    return response.data;
  },

  getCategories: async (): Promise<string[]> => {
    const response = await apiClient.get<string[]>('/products/categories');
    return response.data;
  },
};

// services/api/authApi.ts
import { apiClient } from './apiClient';
import { LoginPayload, AuthResponse, User } from '../../models/User';

export const authApi = {
  login: async (payload: LoginPayload): Promise<AuthResponse> => {
    const response = await apiClient.post<AuthResponse>('/auth/login', payload);
    return response.data;
  },

  register: async (payload: LoginPayload & { name: string }): Promise<AuthResponse> => {
    const response = await apiClient.post<AuthResponse>('/auth/register', payload);
    return response.data;
  },

  getCurrentUser: async (): Promise<User> => {
    const response = await apiClient.get<User>('/auth/me');
    return response.data;
  },

  logout: async (): Promise<void> => {
    await apiClient.post('/auth/logout');
  },
};

ViewModel Layer

// viewmodels/useProductListViewModel.ts
import { useState, useCallback, useEffect, useMemo } from 'react';
import { Product, ProductFilters, PaginatedResponse } from '../models/Product';
import { productApi } from '../services/api/productApi';

interface ProductListState {
  products: Product[];
  isLoading: boolean;
  isRefreshing: boolean;
  isLoadingMore: boolean;
  error: string | null;
  page: number;
  hasMore: boolean;
  filters: ProductFilters;
  categories: string[];
}

const initialState: ProductListState = {
  products: [],
  isLoading: true,
  isRefreshing: false,
  isLoadingMore: false,
  error: null,
  page: 1,
  hasMore: true,
  filters: {},
  categories: [],
};

export function useProductListViewModel() {
  const [state, setState] = useState<ProductListState>(initialState);

  // Computed values
  const filteredProductCount = useMemo(() => {
    return state.products.length;
  }, [state.products]);

  const hasActiveFilters = useMemo(() => {
    return Object.values(state.filters).some(v => v !== undefined);
  }, [state.filters]);

  // Fetch products
  const fetchProducts = useCallback(async (
    page: number = 1,
    filters: ProductFilters = {},
    append: boolean = false
  ) => {
    try {
      if (!append) {
        setState(prev => ({ ...prev, isLoading: true, error: null }));
      }

      const response = await productApi.getProducts(page, filters);

      setState(prev => ({
        ...prev,
        products: append ? [...prev.products, ...response.data] : response.data,
        page: response.page,
        hasMore: response.hasMore,
        isLoading: false,
        isRefreshing: false,
        isLoadingMore: false,
        error: null,
      }));
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Failed to load products';
      setState(prev => ({
        ...prev,
        isLoading: false,
        isRefreshing: false,
        isLoadingMore: false,
        error: message,
      }));
    }
  }, []);

  // Initial load
  useEffect(() => {
    fetchProducts(1, state.filters);
    loadCategories();
  }, []);

  // Load categories
  const loadCategories = useCallback(async () => {
    try {
      const categories = await productApi.getCategories();
      setState(prev => ({ ...prev, categories }));
    } catch (error) {
      console.error('Failed to load categories:', error);
    }
  }, []);

  // Refresh
  const refresh = useCallback(async () => {
    setState(prev => ({ ...prev, isRefreshing: true }));
    await fetchProducts(1, state.filters);
  }, [fetchProducts, state.filters]);

  // Load more (pagination)
  const loadMore = useCallback(async () => {
    if (state.isLoadingMore || !state.hasMore || state.isLoading) return;

    setState(prev => ({ ...prev, isLoadingMore: true }));
    await fetchProducts(state.page + 1, state.filters, true);
  }, [fetchProducts, state.page, state.filters, state.isLoadingMore, state.hasMore, state.isLoading]);

  // Apply filters
  const applyFilters = useCallback((newFilters: ProductFilters) => {
    setState(prev => ({ ...prev, filters: newFilters, page: 1 }));
    fetchProducts(1, newFilters);
  }, [fetchProducts]);

  // Clear filters
  const clearFilters = useCallback(() => {
    setState(prev => ({ ...prev, filters: {}, page: 1 }));
    fetchProducts(1, {});
  }, [fetchProducts]);

  // Retry on error
  const retry = useCallback(() => {
    fetchProducts(1, state.filters);
  }, [fetchProducts, state.filters]);

  return {
    // State
    products: state.products,
    isLoading: state.isLoading,
    isRefreshing: state.isRefreshing,
    isLoadingMore: state.isLoadingMore,
    error: state.error,
    hasMore: state.hasMore,
    filters: state.filters,
    categories: state.categories,
    
    // Computed
    filteredProductCount,
    hasActiveFilters,
    
    // Actions
    refresh,
    loadMore,
    applyFilters,
    clearFilters,
    retry,
  };
}

// viewmodels/useCartViewModel.ts
import { useState, useCallback, useMemo } from 'react';
import { Product } from '../models/Product';
import { CartItem, Cart } from '../models/CartItem';

const TAX_RATE = 0.08;

export function useCartViewModel() {
  const [items, setItems] = useState<CartItem[]>([]);

  // Computed values
  const subtotal = useMemo(() => {
    return items.reduce((sum, item) => sum + item.product.price * item.quantity, 0);
  }, [items]);

  const tax = useMemo(() => subtotal * TAX_RATE, [subtotal]);
  const total = useMemo(() => subtotal + tax, [subtotal, tax]);
  const itemCount = useMemo(() => items.reduce((sum, item) => sum + item.quantity, 0), [items]);
  const isEmpty = items.length === 0;

  // Actions
  const addToCart = useCallback((product: Product, quantity: number = 1) => {
    setItems(prev => {
      const existingIndex = prev.findIndex(item => item.product.id === product.id);
      
      if (existingIndex >= 0) {
        const updated = [...prev];
        updated[existingIndex] = {
          ...updated[existingIndex],
          quantity: updated[existingIndex].quantity + quantity,
        };
        return updated;
      }
      
      return [...prev, { product, quantity }];
    });
  }, []);

  const removeFromCart = useCallback((productId: string) => {
    setItems(prev => prev.filter(item => item.product.id !== productId));
  }, []);

  const updateQuantity = useCallback((productId: string, quantity: number) => {
    if (quantity <= 0) {
      removeFromCart(productId);
      return;
    }

    setItems(prev => 
      prev.map(item => 
        item.product.id === productId 
          ? { ...item, quantity }
          : item
      )
    );
  }, [removeFromCart]);

  const clearCart = useCallback(() => {
    setItems([]);
  }, []);

  const isInCart = useCallback((productId: string) => {
    return items.some(item => item.product.id === productId);
  }, [items]);

  const getItemQuantity = useCallback((productId: string) => {
    return items.find(item => item.product.id === productId)?.quantity ?? 0;
  }, [items]);

  const cart: Cart = {
    items,
    subtotal,
    tax,
    total,
  };

  return {
    // State
    cart,
    items,
    
    // Computed
    subtotal,
    tax,
    total,
    itemCount,
    isEmpty,
    
    // Actions
    addToCart,
    removeFromCart,
    updateQuantity,
    clearCart,
    isInCart,
    getItemQuantity,
  };
}

// viewmodels/useLoginViewModel.ts
import { useState, useCallback } from 'react';
import { LoginPayload, User } from '../models/User';
import { authApi } from '../services/api/authApi';
import { setSecureValue, deleteSecureValue } from '../services/storage/secureStorage';

interface LoginFormState {
  email: string;
  password: string;
}

interface LoginFormErrors {
  email?: string;
  password?: string;
}

export function useLoginViewModel(onLoginSuccess?: (user: User) => void) {
  const [form, setForm] = useState<LoginFormState>({ email: '', password: '' });
  const [errors, setErrors] = useState<LoginFormErrors>({});
  const [isLoading, setIsLoading] = useState(false);
  const [apiError, setApiError] = useState<string | null>(null);

  const updateField = useCallback((field: keyof LoginFormState, value: string) => {
    setForm(prev => ({ ...prev, [field]: value }));
    // Clear error when user types
    if (errors[field]) {
      setErrors(prev => ({ ...prev, [field]: undefined }));
    }
    if (apiError) {
      setApiError(null);
    }
  }, [errors, apiError]);

  const validate = useCallback((): boolean => {
    const newErrors: LoginFormErrors = {};

    if (!form.email.trim()) {
      newErrors.email = 'Email is required';
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
      newErrors.email = 'Please enter a valid email';
    }

    if (!form.password) {
      newErrors.password = 'Password is required';
    } else if (form.password.length < 8) {
      newErrors.password = 'Password must be at least 8 characters';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  }, [form]);

  const login = useCallback(async () => {
    if (!validate()) return;

    setIsLoading(true);
    setApiError(null);

    try {
      const response = await authApi.login(form);
      
      // Store tokens securely
      await setSecureValue('accessToken', response.accessToken);
      await setSecureValue('refreshToken', response.refreshToken);
      
      onLoginSuccess?.(response.user);
    } catch (error) {
      const message = error instanceof Error 
        ? error.message 
        : 'Login failed. Please check your credentials.';
      setApiError(message);
    } finally {
      setIsLoading(false);
    }
  }, [form, validate, onLoginSuccess]);

  const resetForm = useCallback(() => {
    setForm({ email: '', password: '' });
    setErrors({});
    setApiError(null);
  }, []);

  const isValid = !errors.email && !errors.password && form.email && form.password;

  return {
    // State
    form,
    errors,
    isLoading,
    apiError,
    
    // Computed
    isValid,
    
    // Actions
    updateField,
    login,
    resetForm,
  };
}

View Layer

// views/screens/ProductListScreen.tsx
import React, { useCallback } from 'react';
import {
  View,
  FlatList,
  StyleSheet,
  RefreshControl,
  ActivityIndicator,
  Text,
  TouchableOpacity,
} from 'react-native';
import { useProductListViewModel } from '../../viewmodels/useProductListViewModel';
import { useCartViewModel } from '../../viewmodels/useCartViewModel';
import { ProductCard } from '../components/ProductCard';
import { FilterBar } from '../components/FilterBar';
import { ErrorView } from '../components/ErrorView';
import { Product } from '../../models/Product';

export const ProductListScreen: React.FC = () => {
  const {
    products,
    isLoading,
    isRefreshing,
    isLoadingMore,
    error,
    hasMore,
    filters,
    categories,
    hasActiveFilters,
    refresh,
    loadMore,
    applyFilters,
    clearFilters,
    retry,
  } = useProductListViewModel();

  const { addToCart, isInCart, getItemQuantity, itemCount } = useCartViewModel();

  const handleAddToCart = useCallback((product: Product) => {
    addToCart(product, 1);
  }, [addToCart]);

  const renderProduct = useCallback(({ item }: { item: Product }) => (
    <ProductCard
      product={item}
      onAddToCart={() => handleAddToCart(item)}
      isInCart={isInCart(item.id)}
      cartQuantity={getItemQuantity(item.id)}
    />
  ), [handleAddToCart, isInCart, getItemQuantity]);

  const renderFooter = useCallback(() => {
    if (!isLoadingMore) return null;
    return (
      <View style={styles.footer}>
        <ActivityIndicator size="small" />
      </View>
    );
  }, [isLoadingMore]);

  const renderEmpty = useCallback(() => {
    if (isLoading) return null;
    return (
      <View style={styles.emptyContainer}>
        <Text style={styles.emptyText}>
          {hasActiveFilters 
            ? 'No products match your filters' 
            : 'No products available'}
        </Text>
        {hasActiveFilters && (
          <TouchableOpacity onPress={clearFilters} style={styles.clearButton}>
            <Text style={styles.clearButtonText}>Clear Filters</Text>
          </TouchableOpacity>
        )}
      </View>
    );
  }, [isLoading, hasActiveFilters, clearFilters]);

  if (isLoading && products.length === 0) {
    return (
      <View style={styles.centerContainer}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  if (error && products.length === 0) {
    return <ErrorView message={error} onRetry={retry} />;
  }

  return (
    <View style={styles.container}>
      <FilterBar
        categories={categories}
        activeFilters={filters}
        onApplyFilters={applyFilters}
        onClearFilters={clearFilters}
      />
      
      <FlatList
        data={products}
        renderItem={renderProduct}
        keyExtractor={(item) => item.id}
        numColumns={2}
        contentContainerStyle={styles.listContent}
        columnWrapperStyle={styles.row}
        refreshControl={
          <RefreshControl
            refreshing={isRefreshing}
            onRefresh={refresh}
          />
        }
        onEndReached={loadMore}
        onEndReachedThreshold={0.5}
        ListFooterComponent={renderFooter}
        ListEmptyComponent={renderEmpty}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
  centerContainer: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  listContent: {
    padding: 8,
  },
  row: {
    justifyContent: 'space-between',
  },
  footer: {
    paddingVertical: 20,
    alignItems: 'center',
  },
  emptyContainer: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 40,
  },
  emptyText: {
    fontSize: 16,
    color: '#666',
    textAlign: 'center',
  },
  clearButton: {
    marginTop: 16,
    padding: 12,
  },
  clearButtonText: {
    color: '#007AFF',
    fontSize: 16,
  },
});

// views/screens/LoginScreen.tsx
import React from 'react';
import {
  View,
  TextInput,
  TouchableOpacity,
  Text,
  StyleSheet,
  ActivityIndicator,
  KeyboardAvoidingView,
  Platform,
  ScrollView,
} from 'react-native';
import { useLoginViewModel } from '../../viewmodels/useLoginViewModel';
import { useNavigation } from '@react-navigation/native';

export const LoginScreen: React.FC = () => {
  const navigation = useNavigation();
  
  const {
    form,
    errors,
    isLoading,
    apiError,
    isValid,
    updateField,
    login,
  } = useLoginViewModel((user) => {
    // Navigate to main app on success
    navigation.reset({
      index: 0,
      routes: [{ name: 'Main' }],
    });
  });

  return (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
    >
      <ScrollView
        contentContainerStyle={styles.scrollContent}
        keyboardShouldPersistTaps="handled"
      >
        <View style={styles.form}>
          <Text style={styles.title}>Welcome Back</Text>
          
          {apiError && (
            <View style={styles.errorBanner}>
              <Text style={styles.errorBannerText}>{apiError}</Text>
            </View>
          )}

          <View style={styles.inputContainer}>
            <TextInput
              style={[styles.input, errors.email && styles.inputError]}
              placeholder="Email"
              value={form.email}
              onChangeText={(text) => updateField('email', text)}
              keyboardType="email-address"
              autoCapitalize="none"
              autoComplete="email"
              editable={!isLoading}
            />
            {errors.email && (
              <Text style={styles.errorText}>{errors.email}</Text>
            )}
          </View>

          <View style={styles.inputContainer}>
            <TextInput
              style={[styles.input, errors.password && styles.inputError]}
              placeholder="Password"
              value={form.password}
              onChangeText={(text) => updateField('password', text)}
              secureTextEntry
              autoComplete="password"
              editable={!isLoading}
            />
            {errors.password && (
              <Text style={styles.errorText}>{errors.password}</Text>
            )}
          </View>

          <TouchableOpacity
            style={[styles.button, (!isValid || isLoading) && styles.buttonDisabled]}
            onPress={login}
            disabled={!isValid || isLoading}
          >
            {isLoading ? (
              <ActivityIndicator color="#fff" />
            ) : (
              <Text style={styles.buttonText}>Log In</Text>
            )}
          </TouchableOpacity>
        </View>
      </ScrollView>
    </KeyboardAvoidingView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
  },
  scrollContent: {
    flexGrow: 1,
    justifyContent: 'center',
  },
  form: {
    padding: 24,
  },
  title: {
    fontSize: 28,
    fontWeight: 'bold',
    marginBottom: 32,
    textAlign: 'center',
  },
  inputContainer: {
    marginBottom: 16,
  },
  input: {
    height: 50,
    borderWidth: 1,
    borderColor: '#ddd',
    borderRadius: 8,
    paddingHorizontal: 16,
    fontSize: 16,
  },
  inputError: {
    borderColor: '#ff3b30',
  },
  errorText: {
    color: '#ff3b30',
    fontSize: 12,
    marginTop: 4,
  },
  errorBanner: {
    backgroundColor: '#ffebee',
    padding: 12,
    borderRadius: 8,
    marginBottom: 16,
  },
  errorBannerText: {
    color: '#c62828',
    textAlign: 'center',
  },
  button: {
    height: 50,
    backgroundColor: '#007AFF',
    borderRadius: 8,
    justifyContent: 'center',
    alignItems: 'center',
    marginTop: 8,
  },
  buttonDisabled: {
    opacity: 0.6,
  },
  buttonText: {
    color: '#fff',
    fontSize: 16,
    fontWeight: '600',
  },
});

Testing ViewModels

One of MVVM’s biggest advantages is testability. ViewModels can be tested independently of the UI:

// __tests__/viewmodels/useCartViewModel.test.ts
import { renderHook, act } from '@testing-library/react-hooks';
import { useCartViewModel } from '../../viewmodels/useCartViewModel';

const mockProduct = {
  id: '1',
  name: 'Test Product',
  description: 'A test product',
  price: 29.99,
  imageUrl: 'https://example.com/image.jpg',
  category: 'Electronics',
  inStock: true,
  rating: 4.5,
  reviewCount: 100,
};

describe('useCartViewModel', () => {
  it('starts with empty cart', () => {
    const { result } = renderHook(() => useCartViewModel());
    
    expect(result.current.isEmpty).toBe(true);
    expect(result.current.itemCount).toBe(0);
    expect(result.current.total).toBe(0);
  });

  it('adds product to cart', () => {
    const { result } = renderHook(() => useCartViewModel());
    
    act(() => {
      result.current.addToCart(mockProduct, 2);
    });
    
    expect(result.current.isEmpty).toBe(false);
    expect(result.current.itemCount).toBe(2);
    expect(result.current.isInCart(mockProduct.id)).toBe(true);
  });

  it('calculates totals correctly', () => {
    const { result } = renderHook(() => useCartViewModel());
    
    act(() => {
      result.current.addToCart(mockProduct, 3);
    });
    
    const expectedSubtotal = 29.99 * 3;
    const expectedTax = expectedSubtotal * 0.08;
    
    expect(result.current.subtotal).toBeCloseTo(expectedSubtotal);
    expect(result.current.tax).toBeCloseTo(expectedTax);
    expect(result.current.total).toBeCloseTo(expectedSubtotal + expectedTax);
  });

  it('increases quantity when adding existing product', () => {
    const { result } = renderHook(() => useCartViewModel());
    
    act(() => {
      result.current.addToCart(mockProduct, 1);
      result.current.addToCart(mockProduct, 2);
    });
    
    expect(result.current.getItemQuantity(mockProduct.id)).toBe(3);
  });

  it('removes product from cart', () => {
    const { result } = renderHook(() => useCartViewModel());
    
    act(() => {
      result.current.addToCart(mockProduct, 1);
      result.current.removeFromCart(mockProduct.id);
    });
    
    expect(result.current.isEmpty).toBe(true);
    expect(result.current.isInCart(mockProduct.id)).toBe(false);
  });
});

Pros of MVVM in React Native

  • Separation of concerns: UI logic is isolated from business logic
  • Easier to test: ViewModels can be unit tested without rendering components
  • Reusable view models: Logic can be shared across screens
  • Cleaner code: Complex state management is organized and predictable
  • Better maintainability: Changes to business logic don’t require touching UI code

Cons of MVVM in React Native

  • Overhead for small apps: Simple apps don’t need this level of abstraction
  • Learning curve: Team members unfamiliar with the pattern need onboarding
  • Over-abstraction risk: Can lead to unnecessary complexity if applied dogmatically
  • More files: Requires more boilerplate compared to keeping everything in components

Common Mistakes to Avoid

Putting UI Logic in ViewModels

ViewModels should handle business logic and state, not UI concerns like animations, focus management, or navigation timing. Keep presentation logic in your View components.

Creating ViewModels for Every Component

Not every component needs a ViewModel. Simple presentational components like buttons or cards should remain simple. Only create ViewModels for screens or complex feature components.

Coupling ViewModels to Specific Views

ViewModels should be reusable. Avoid referencing specific component structures or styles in your ViewModel logic.

Forgetting Cleanup

If your ViewModel sets up subscriptions or timers, make sure to clean them up in the hook’s cleanup function.

When Should You Use MVVM?

Project Type MVVM Worth It?
Simple apps (1-5 screens) Not needed
Medium-sized apps (5-15 screens) Helpful for complex screens
Large-scale apps (15+ screens) Highly recommended
Team projects Provides consistency
Apps with complex business logic Essential

Conclusion

MVVM isn’t “required” in React Native, but it’s incredibly helpful once your app grows beyond a few screens. By introducing ViewModels via custom hooks, you get better testability, clearer structure, and a cleaner separation of concerns.

If you’re already using context, Redux, or Zustand—MVVM can sit on top as an organizing principle rather than a replacement. The ViewModel hooks can internally use whatever state management solution you prefer.

For more React Native architecture patterns, check out our comparison of Context API vs Redux. To explore the essential libraries for production React Native apps, see our guide on React Native Libraries for 2025. And for official documentation, visit the React Native documentation.

Leave a Comment