ViewModel Layer
The ViewModel is the bridge between Model and View. It contains presentation logic, manages UI state, formats data for display, and exposes actions that Views can execute.
What is a ViewModel?
In React Native with MVVM, ViewModels are custom hooks that:
- Manage UI state - Handle loading, errors, form state
- Format data - Transform Model data for display
- Expose actions - Provide functions Views can call
- Orchestrate logic - Coordinate Model operations
View (UI Component)
│
│ observes state
│ calls actions
▼
ViewModel (Custom Hook)
│
│ manages state
│ transforms data
│ coordinates
▼
Model (Data & Logic)
│
└── API, Storage, Business LogicFolder Structure
src/
└── presentation/
├── screens/ # Views
│ ├── ProfileScreen.tsx
│ ├── SettingsScreen.tsx
│ └── TaskListScreen.tsx
│
└── viewmodels/ # ViewModels
├── useProfileViewModel.ts
├── useSettingsViewModel.ts
└── useTaskListViewModel.tsBasic ViewModel Example
Here's a simple ViewModel that manages user profile data:
import { useCallback } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { router } from 'expo-router';
import { userApi } from '@/data/api/endpoints/users';
import { useAuthStore } from '@/stores/auth.store';
export function useProfileViewModel() {
// Get user ID from global state
const userId = useAuthStore(state => state.userId);
// Fetch user data (React Query)
const {
data: user,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['user', userId],
queryFn: () => userApi.getUser(userId),
enabled: !!userId,
});
// Update profile mutation
const updateMutation = useMutation({
mutationFn: userApi.updateProfile,
onSuccess: () => {
refetch();
},
});
// Computed/formatted data
const stats = {
posts: user?.posts?.length ?? 0,
followers: user?.followers ?? 0,
following: user?.following ?? 0,
};
const displayName = user?.name ?? 'Guest';
const initials = displayName
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase();
// Actions
const handleEditProfile = useCallback(() => {
router.push('/edit-profile');
}, []);
const handleShare = useCallback(() => {
// Share logic here
console.log('Sharing profile');
}, []);
const handleRefresh = useCallback(() => {
refetch();
}, [refetch]);
const handleUpdateProfile = useCallback((data) => {
updateMutation.mutate(data);
}, [updateMutation]);
// Return interface for the View
return {
// Data
user,
stats,
displayName,
initials,
// State
isLoading,
error: error?.message,
isUpdating: updateMutation.isPending,
// Actions
handleEditProfile,
handleShare,
handleRefresh,
handleUpdateProfile,
};
}ViewModel Responsibilities
1. State Management
ViewModels manage all UI state:
export function useTaskListViewModel() {
// UI state
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
const [sortBy, setSortBy] = useState<'date' | 'title'>('date');
// Data fetching state (React Query handles this)
const { data, isLoading, error } = useQuery({
queryKey: ['tasks'],
queryFn: taskApi.getTasks,
});
// Filtered and sorted data
const filteredTasks = useMemo(() => {
let tasks = data ?? [];
// Apply filter
if (filter === 'active') {
tasks = tasks.filter(t => !t.completed);
} else if (filter === 'completed') {
tasks = tasks.filter(t => t.completed);
}
// Apply sort
return tasks.sort((a, b) => {
if (sortBy === 'date') {
return b.createdAt.getTime() - a.createdAt.getTime();
}
return a.title.localeCompare(b.title);
});
}, [data, filter, sortBy]);
return {
tasks: filteredTasks,
filter,
sortBy,
isLoading,
error,
setFilter,
setSortBy,
};
}2. Data Formatting
Transform raw data into display-ready format:
export function useUserDetailViewModel(userId: string) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => userApi.getUser(userId),
});
// Format data for display
const formattedJoinDate = user?.createdAt
? format(user.createdAt, 'MMMM yyyy')
: '';
const isVerified = user?.emailVerified && user?.phoneVerified;
const statusText = user?.isActive
? '🟢 Active'
: '⚪ Inactive';
const bio = user?.bio || 'No bio yet';
const socialLinks = [
user?.twitter && { icon: 'twitter', url: user.twitter },
user?.github && { icon: 'github', url: user.github },
user?.linkedin && { icon: 'linkedin', url: user.linkedin },
].filter(Boolean);
return {
user,
formattedJoinDate,
isVerified,
statusText,
bio,
socialLinks,
};
}3. Form Handling
Manage forms with validation:
export function useLoginViewModel() {
const { t } = useTranslation();
const login = useAuthStore(state => state.login);
// Form with validation
const form = useForm({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
});
// Login mutation
const loginMutation = useMutation({
mutationFn: authApi.login,
onSuccess: (data) => {
login(data.token, data.user);
router.replace('/home');
},
onError: (error) => {
form.setError('root', {
message: error.message,
});
},
});
const handleLogin = useCallback((data) => {
loginMutation.mutate(data);
}, [loginMutation]);
const handleForgotPassword = useCallback(() => {
router.push('/forgot-password');
}, []);
return {
form,
isLoading: loginMutation.isPending,
error: form.formState.errors.root?.message,
handleLogin,
handleForgotPassword,
};
}4. Orchestrating Operations
Coordinate multiple Model operations:
export function useCheckoutViewModel() {
const cart = useCartStore(state => state.items);
const clearCart = useCartStore(state => state.clear);
const createOrderMutation = useMutation({
mutationFn: async (input) => {
// 1. Create order
const order = await orderApi.createOrder(input);
// 2. Process payment
const payment = await paymentApi.processPayment({
orderId: order.id,
amount: order.total,
});
// 3. Send confirmation email
await notificationApi.sendOrderConfirmation({
orderId: order.id,
email: input.email,
});
return { order, payment };
},
onSuccess: () => {
clearCart();
router.push('/order-success');
},
});
const total = cart.reduce((sum, item) =>
sum + item.price * item.quantity, 0
);
const handleCheckout = useCallback((paymentInfo) => {
createOrderMutation.mutate({
items: cart,
...paymentInfo,
});
}, [cart, createOrderMutation]);
return {
cart,
total,
isProcessing: createOrderMutation.isPending,
handleCheckout,
};
}ViewModel Best Practices
✅ One ViewModel per screen
Each screen should have its own ViewModel that provides everything the View needs.
✅ Return a clean interface
Group returned values logically: data, state, actions. Make it easy for Views to consume.
✅ Use useCallback for actions
Wrap action functions in useCallback to prevent unnecessary re-renders.
✅ Keep Views simple
Move all logic to ViewModels. Views should just render and call actions.
✅ Format data in ViewModel
Transform dates, currencies, and other display formatting in the ViewModel, not the View.
Testing ViewModels
ViewModels are easy to test because they're just hooks:
import { renderHook, act } from '@testing-library/react-hooks';
import { useProfileViewModel } from './useProfileViewModel';
describe('useProfileViewModel', () => {
it('fetches user data', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useProfileViewModel()
);
expect(result.current.isLoading).toBe(true);
await waitForNextUpdate();
expect(result.current.user).toBeDefined();
expect(result.current.isLoading).toBe(false);
});
it('navigates to edit screen', () => {
const { result } = renderHook(() => useProfileViewModel());
act(() => {
result.current.handleEditProfile();
});
expect(router.push).toHaveBeenCalledWith('/edit-profile');
});
});Common Patterns
Pagination
export function useInfiniteListViewModel() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 1 }) =>
postApi.getPosts({ page: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextPage,
});
const posts = data?.pages.flatMap(page => page.items) ?? [];
const handleLoadMore = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return {
posts,
hasMore: hasNextPage,
isLoadingMore: isFetchingNextPage,
handleLoadMore,
};
}Search with Debounce
export function useSearchViewModel() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
const { data, isLoading } = useQuery({
queryKey: ['search', debouncedQuery],
queryFn: () => searchApi.search(debouncedQuery),
enabled: debouncedQuery.length > 2,
});
const handleSearch = useCallback((text: string) => {
setQuery(text);
}, []);
const handleClear = useCallback(() => {
setQuery('');
}, []);
return {
query,
results: data ?? [],
isSearching: isLoading,
handleSearch,
handleClear,
};
}Complete Example
Here's a complete ViewModel with all best practices:
import { useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { router } from 'expo-router';
import { taskApi } from '@/data/api/endpoints/tasks';
import { useTaskStore } from '@/stores/task.store';
import { createTaskSchema } from '@/domain/schemas/task.schema';
import type { CreateTaskInput } from '@/domain/entities/task';
export function useTaskManagementViewModel() {
const queryClient = useQueryClient();
const localTasks = useTaskStore(state => state.tasks);
// Data fetching
const {
data: serverTasks,
isLoading,
error
} = useQuery({
queryKey: ['tasks'],
queryFn: taskApi.getTasks,
});
// Mutations
const createMutation = useMutation({
mutationFn: taskApi.createTask,
onSuccess: () => {
queryClient.invalidateQueries(['tasks']);
},
});
const deleteMutation = useMutation({
mutationFn: taskApi.deleteTask,
onSuccess: () => {
queryClient.invalidateQueries(['tasks']);
},
});
// Form
const form = useForm<CreateTaskInput>({
resolver: zodResolver(createTaskSchema),
defaultValues: {
title: '',
description: '',
},
});
// Computed data
const allTasks = useMemo(() =>
[...localTasks, ...(serverTasks ?? [])]
, [localTasks, serverTasks]);
const stats = useMemo(() => ({
total: allTasks.length,
active: allTasks.filter(t => !t.completed).length,
completed: allTasks.filter(t => t.completed).length,
}), [allTasks]);
// Actions
const handleCreate = useCallback((data: CreateTaskInput) => {
createMutation.mutate(data);
form.reset();
}, [createMutation, form]);
const handleDelete = useCallback((id: string) => {
deleteMutation.mutate(id);
}, [deleteMutation]);
const handleNavigateToDetail = useCallback((id: string) => {
router.push(`/tasks/${id}`);
}, []);
// Return interface
return {
// Data
tasks: allTasks,
stats,
// Form
form,
// State
isLoading,
error: error?.message,
isCreating: createMutation.isPending,
isDeleting: deleteMutation.isPending,
// Actions
handleCreate,
handleDelete,
handleNavigateToDetail,
};
}