
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.