View Layer
The View layer is responsible for rendering the user interface. In MVVM, Views are "dumb" components that only display data and delegate all logic to ViewModels.
What is the View Layer?
Views in MVVM are React components that:
- Render UI - Display data from ViewModels
- Capture input - Handle user interactions
- Delegate logic - Call ViewModel actions
- No business logic - Stay pure and testable
Views should never contain business logic, API calls, or state management. All of that belongs in the ViewModel.
Folder Structure
typescript
src/
├── app/ # Routes (Expo Router)
│ ├── (tabs)/
│ │ ├── index.tsx # Home route
│ │ ├── profile.tsx # Profile route
│ │ └── settings.tsx # Settings route
│ └── user/
│ └── [id].tsx # Dynamic user route
│
└── presentation/
└── screens/ # Screen components (Views)
├── HomeScreen.tsx
├── ProfileScreen.tsx
├── SettingsScreen.tsx
└── UserDetailScreen.tsxView Responsibilities
✅ Views Should
- • Render UI based on ViewModel state
- • Call ViewModel actions
- • Handle layout and styling
- • Be easily testable
❌ Views Should NOT
- • Contain business logic
- • Make API calls directly
- • Manage complex state
- • Format or transform data
Basic View Example
Here's a simple view that follows MVVM principles:
src/presentation/screens/ProfileScreen.tsx
import { View, Text, Image, TouchableOpacity, ScrollView } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useProfileViewModel } from '../viewmodels/useProfileViewModel';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { ErrorMessage } from '@/components/ui/ErrorMessage';
export function ProfileScreen() {
// Get all data and actions from ViewModel
const vm = useProfileViewModel();
// Loading state
if (vm.isLoading) {
return <LoadingSpinner />;
}
// Error state
if (vm.error) {
return (
<ErrorMessage
message={vm.error}
onRetry={vm.handleRefresh}
/>
);
}
// Main UI
return (
<SafeAreaView className="flex-1 bg-slate-950">
<ScrollView className="flex-1 px-4">
{/* Header */}
<View className="py-6 items-center">
<Image
source={{ uri: vm.user.avatar }}
className="w-24 h-24 rounded-full"
/>
<Text className="text-2xl font-bold text-white mt-4">
{vm.user.name}
</Text>
<Text className="text-slate-400">
{vm.user.email}
</Text>
</View>
{/* Stats */}
<View className="flex-row gap-4 mb-6">
<View className="flex-1 glass rounded-xl p-4 items-center">
<Text className="text-white text-2xl font-bold">
{vm.stats.posts}
</Text>
<Text className="text-slate-400 text-sm">Posts</Text>
</View>
<View className="flex-1 glass rounded-xl p-4 items-center">
<Text className="text-white text-2xl font-bold">
{vm.stats.followers}
</Text>
<Text className="text-slate-400 text-sm">Followers</Text>
</View>
<View className="flex-1 glass rounded-xl p-4 items-center">
<Text className="text-white text-2xl font-bold">
{vm.stats.following}
</Text>
<Text className="text-slate-400 text-sm">Following</Text>
</View>
</View>
{/* Actions */}
<View className="gap-3">
<TouchableOpacity
onPress={vm.handleEditProfile}
className="bg-blue-500 rounded-xl py-4"
>
<Text className="text-white text-center font-semibold">
Edit Profile
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={vm.handleShare}
className="glass rounded-xl py-4"
>
<Text className="text-white text-center font-semibold">
Share Profile
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
);
}Notice how the View only renders data and calls actions. No logic, no data manipulation, no API calls.
Route Integration
Connect your screen to a route using Expo Router:
src/app/(tabs)/profile.tsx
import { ProfileScreen } from '@/presentation/screens/ProfileScreen';
export default ProfileScreen;Expo Router files should just export the screen component. Keep all logic in the ViewModel.
Common View Patterns
Conditional Rendering
typescript
export function UserListScreen() {
const vm = useUserListViewModel();
return (
<View className="flex-1">
{vm.isLoading && <LoadingSpinner />}
{vm.error && (
<ErrorMessage
message={vm.error}
onRetry={vm.handleRefresh}
/>
)}
{vm.isEmpty && (
<EmptyState message="No users found" />
)}
{vm.users.length > 0 && (
<FlatList
data={vm.users}
renderItem={({ item }) => (
<UserCard
user={item}
onPress={() => vm.handleUserPress(item.id)}
/>
)}
keyExtractor={(item) => item.id}
/>
)}
</View>
);
}Form Handling
typescript
export function LoginScreen() {
const vm = useLoginViewModel();
return (
<View className="flex-1 p-4">
<Controller
control={vm.form.control}
name="email"
render={({ field, fieldState }) => (
<Input
label="Email"
value={field.value}
onChangeText={field.onChange}
error={fieldState.error?.message}
keyboardType="email-address"
autoCapitalize="none"
/>
)}
/>
<Controller
control={vm.form.control}
name="password"
render={({ field, fieldState }) => (
<Input
label="Password"
value={field.value}
onChangeText={field.onChange}
error={fieldState.error?.message}
secureTextEntry
/>
)}
/>
<Button
onPress={vm.form.handleSubmit(vm.handleLogin)}
loading={vm.isLoading}
>
Login
</Button>
</View>
);
}List with Actions
typescript
export function TaskListScreen() {
const vm = useTaskListViewModel();
return (
<FlatList
data={vm.tasks}
renderItem={({ item }) => (
<View className="glass rounded-xl p-4 mb-3">
<View className="flex-row items-center justify-between">
<TouchableOpacity
onPress={() => vm.handleToggleTask(item.id)}
className="flex-row items-center flex-1"
>
<View className={`w-6 h-6 rounded-full border-2 mr-3 ${
item.completed ? 'bg-blue-500 border-blue-500' : 'border-slate-600'
}`} />
<Text className={`text-white ${
item.completed ? 'line-through opacity-50' : ''
}`}>
{item.title}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => vm.handleDeleteTask(item.id)}
>
<Text className="text-red-400">Delete</Text>
</TouchableOpacity>
</View>
</View>
)}
keyExtractor={(item) => item.id}
ListEmptyComponent={() => (
<Text className="text-slate-400 text-center">
No tasks yet
</Text>
)}
/>
);
}Styling Views
Use NativeWind (Tailwind CSS) for styling:
typescript
// ✅ Good - Using utility classes
<View className="flex-1 bg-slate-950 p-4">
<Text className="text-2xl font-bold text-white mb-4">
Title
</Text>
<TouchableOpacity className="bg-blue-500 rounded-xl py-4">
<Text className="text-white text-center font-semibold">
Button
</Text>
</TouchableOpacity>
</View>
// ❌ Avoid - Inline styles
<View style={{ flex: 1, backgroundColor: '#020617', padding: 16 }}>
<Text style={{ fontSize: 24, fontWeight: 'bold', color: 'white' }}>
Title
</Text>
</View>View Best Practices
✅ Use descriptive names
Name components clearly: ProfileScreen, UserListScreen, not Screen1.
✅ Keep Views simple
If a View becomes too complex, split it into smaller components.
✅ One ViewModel per screen
Each screen should have its own ViewModel that provides all needed data and actions.
✅ Handle all states
Always show loading, error, and empty states - not just the success case.
Testing Views
Views are easy to test because they're pure components:
typescript
import { render, fireEvent } from '@testing-library/react-native';
import { ProfileScreen } from './ProfileScreen';
import * as viewModel from '../viewmodels/useProfileViewModel';
describe('ProfileScreen', () => {
it('displays user information', () => {
jest.spyOn(viewModel, 'useProfileViewModel').mockReturnValue({
user: { name: 'John Doe', email: 'john@example.com' },
isLoading: false,
error: null,
handleEditProfile: jest.fn(),
});
const { getByText } = render(<ProfileScreen />);
expect(getByText('John Doe')).toBeTruthy();
expect(getByText('john@example.com')).toBeTruthy();
});
it('calls handleEditProfile when button pressed', () => {
const mockHandleEdit = jest.fn();
jest.spyOn(viewModel, 'useProfileViewModel').mockReturnValue({
user: { name: 'John Doe' },
handleEditProfile: mockHandleEdit,
});
const { getByText } = render(<ProfileScreen />);
fireEvent.press(getByText('Edit Profile'));
expect(mockHandleEdit).toHaveBeenCalled();
});
});