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.tsx

View 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();
  });
});

Next Steps