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 Logic

Folder Structure

typescript
src/
└── presentation/
    ├── screens/                # Views
    │   ├── ProfileScreen.tsx
    │   ├── SettingsScreen.tsx
    │   └── TaskListScreen.tsx
    │
    └── viewmodels/             # ViewModels
        ├── useProfileViewModel.ts
        ├── useSettingsViewModel.ts
        └── useTaskListViewModel.ts

Basic ViewModel Example

Here's a simple ViewModel that manages user profile data:

src/presentation/viewmodels/useProfileViewModel.ts
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,
  };
}
ViewModels are custom hooks that encapsulate all screen logic. Views just consume them.

ViewModel Responsibilities

1. State Management

ViewModels manage all UI state:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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

typescript
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

typescript
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:

Production-ready ViewModel
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,
  };
}

Next Steps