Skip to content
intermediate Phase 3 · App Lifecycle & Architecture

MVVM Pattern

Implement Model-View-ViewModel with SwiftUI: observable objects, view models, and data flow.

55m
3 problems
Topic Progress 0%

MVVM Architecture

What Is MVVM?

MVVM (Model-View-ViewModel) is an architectural pattern that separates an app into three layers:

  • Model - Data types and business logic (User, Product, API response)
  • View - The UI layer that displays data (SwiftUI views)
  • ViewModel - The bridge that holds UI state and prepares data for the view
View <-> ViewModel <-> Model

The View observes the ViewModel. The ViewModel never references the View. This one-way dependency keeps layers decoupled and testable.

Why MVVM for SwiftUI?

SwiftUI naturally supports MVVM because:

  • Views are structs that react to state changes
  • @Observable creates observable objects
  • ViewModels can be injected via @Environment or @Bindable
  • The view body is a pure function of its state

Basic MVVM Structure

// Model
struct User: Codable, Identifiable {
    let id: UUID
    let name: String
    let email: String
}

// ViewModel
@Observable
class UserListViewModel {
    var users: [User] = []
    var isLoading = false
    var errorMessage: String?
    
    private let apiService: ApiService
    
    init(apiService: ApiService = LiveApiService()) {
        self.apiService = apiService
    }
    
    func fetchUsers() async {
        isLoading = true
        defer { isLoading = false }
        do {
            users = try await apiService.fetchUsers()
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

// View
struct UserListView: View {
    @State private var viewModel = UserListViewModel()
    
    var body: some View {
        List(viewModel.users) { user in
            Text(user.name)
        }
        .overlay {
            if viewModel.isLoading { ProgressView() }
        }
        .task { await viewModel.fetchUsers() }
    }
}

Separation of Concerns

Layer Responsibility Does NOT
View Display UI, handle user input Make API calls, store business logic
ViewModel Hold UI state, format data, coordinate actions Know about UIView or SwiftUI
Model Represent data, encode/decode Know about ViewModels or Views

ViewModel as Single Source of Truth

The ViewModel owns all state the view needs:

@Observable
class ProductViewModel {
    var products: [Product] = []
    var searchText = ""
    var selectedCategory: Category?
    var sortOption: SortOption = .name
    
    var filteredProducts: [Product] {
        products
            .filter { selectedCategory == nil || $0.category == selectedCategory }
            .filter { searchText.isEmpty || $0.name.localizedCaseInsensitiveContains(searchText) }
            .sorted(by: sortOption.comparator)
    }
}

The view simply displays viewModel.filteredProducts without knowing the filtering logic.

Observable ViewModels

@Observable Macro (iOS 17+)

The @Observable macro is the modern way to create ViewModels:

@Observable
class ProfileViewModel {
    var name = ""
    var email = ""
    var avatarURL: URL?
    var isSaving = false
    
    func save() async {
        isSaving = true
        defer { isSaving = false }
        // save logic
    }
}

@Observable automatically tracks which properties are read in the view body and only triggers updates when those specific properties change.

Legacy: ObservableObject with @Published

For iOS 16 and earlier:

class ProfileViewModel: ObservableObject {
    @Published var name = ""
    @Published var email = ""
    @Published var avatarURL: URL?
    @Published var isSaving = false
    
    func save() async {
        isSaving = true
        defer { isSaving = false }
    }
}

Injecting ViewModels

// Direct injection (preferred for most cases)
struct ProfileView: View {
    @State private var viewModel = ProfileViewModel()
    
    var body: some View {
        Form {
            TextField("Name", text: $viewModel.name)
            TextField("Email", text: $viewModel.email)
        }
        .overlay {
            if viewModel.isSaving { ProgressView() }
        }
    }
}

// Environment injection (for shared state)
@main
struct MyApp: App {
    @State var userStore = UserStore()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(userStore)
        }
    }
}

ViewModel Coordination

For complex screens with multiple ViewModels:

@Observable
class CheckoutViewModel {
    var cart: CartViewModel
    var shipping: ShippingViewModel
    var payment: PaymentViewModel
    
    var isComplete: Bool {
        cart.isNotEmpty && shipping.isValid && payment.isReady
    }
    
    func checkout() async {
        guard isComplete else { return }
        // coordinate checkout across all VMs
    }
}

Avoiding Common Pitfalls

Do not store view-specific state in ViewModel:

// BAD: ViewModel knows about view presentation
@Observable
class SettingsViewModel {
    var isShowingAlert = false  // This is view state
    var alertMessage = ""      // This is view state
}

// GOOD: ViewModel exposes data, View manages presentation
@Observable
class SettingsViewModel {
    var saveResult: SaveResult?
}

struct SettingsView: View {
    @State var viewModel = SettingsViewModel()
    @State var showAlert = false
    
    var body: some View {
        // View decides when to show alert
    }
}

Data Flow in MVVM

View-to-ViewModel Flow

Views communicate with ViewModels through method calls and property mutations:

@Observable
class TodoViewModel {
    var items: [TodoItem] = []
    var newTitle = ""
    
    func addItem() {
        guard !newTitle.isEmpty else { return }
        let item = TodoItem(id: UUID(), title: newTitle, isComplete: false)
        items.append(item)
        newTitle = ""
    }
    
    func toggleItem(_ item: TodoItem) {
        if let index = items.firstIndex(where: { $0.id == item.id }) {
            items[index].isComplete.toggle()
        }
    }
    
    func deleteItems(at offsets: IndexSet) {
        items.remove(atOffsets: offsets)
    }
}

struct TodoView: View {
    @State private var viewModel = TodoViewModel()
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(viewModel.items) { item in
                    TodoRow(item: item) {
                        viewModel.toggleItem(item)
                    }
                }
                .onDelete(perform: viewModel.deleteItems)
            }
            .toolbar {
                TextField("New item", text: $viewModel.newTitle)
                Button("Add") { viewModel.addItem() }
            }
        }
    }
}

ViewModel-to-View Flow

ViewModels notify views through property changes. Views observe and react:

@Observable
class SearchViewModel {
    var query = ""
    var results: [SearchResult] = []
    var isLoading = false
    
    func search() async {
        isLoading = true
        defer { isLoading = false }
        results = await searchService.search(query: query)
    }
}

struct SearchView: View {
    @State var viewModel = SearchViewModel()
    
    var body: some View {
        VStack {
            TextField("Search", text: $viewModel.query)
            
            if viewModel.isLoading {
                ProgressView()
            } else {
                List(viewModel.results) { result in
                    Text(result.title)
                }
            }
        }
        .task(id: viewModel.query) {
            await viewModel.search()
        }
    }
}

Unidirectional Data Flow

MVVM in SwiftUI follows unidirectional data flow:

User Input -> View -> ViewModel (mutate state) -> View (re-renders)

The view never directly mutates model data. The ViewModel processes input, updates state, and the view automatically re-renders.

Testing ViewModels

ViewModels are easily testable because they do not depend on UIKit or SwiftUI:

@Observable
class UserViewModel {
    var users: [User] = []
    var error: String?
    
    private let repository: UserRepository
    
    init(repository: UserRepository) {
        self.repository = repository
    }
    
    func loadUsers() async {
        do {
            users = try await repository.fetchUsers()
        } catch {
            self.error = error.localizedDescription
        }
    }
}

// Test
func testLoadUsers() async {
    let mockRepo = MockUserRepository()
    mockRepo.usersToReturn = [User.mock]
    let vm = UserViewModel(repository: mockRepo)
    
    await vm.loadUsers()
    
    XCTAssertEqual(vm.users.count, 1)
    XCTAssertNil(vm.error)
}

When to Use MVVM

  • Screens with business logic - filtering, validation, data transformation
  • Network-heavy screens - API calls, data mapping, error handling
  • Complex forms - multi-step validation, conditional fields
  • Shared state - user session, settings, cart

For simple display-only views, MVVM is unnecessary overhead. Keep the ViewModel only when it adds clear value.

Quiz

1. What are the three layers in MVVM?

Question 1 options

2. What does the ViewModel NOT reference?

Question 2 options

3. How does the View react to ViewModel state changes in SwiftUI?

Question 3 options

4. Why is MVVM testable?

Question 4 options

5. Where should presentation state like alert visibility be managed?

Question 5 options

Flashcards

Question

What does MVVM stand for?

Answer

Model-View-ViewModel. Model holds data, View displays UI, ViewModel bridges them with state and logic.

Question

Does the ViewModel reference the View?

Answer

No. The ViewModel never references the View. It exposes state; the View observes and reacts.

Question

How do you make a ViewModel observable in iOS 17+?

Answer

Use the @Observable macro, which tracks property access and triggers granular view updates.

Question

Where does presentation state belong?

Answer

In the View using @State. The ViewModel provides data; the View manages presentation (alerts, sheets).

Question

When should you use MVVM?

Answer

When screens have business logic, network calls, complex forms, or shared state.

Revision Notes

Key Takeaways

  • 1. MVVM separates Model, View, and ViewModel for clean architecture
  • 2. ViewModel holds UI state and business logic; View displays it
  • 3. @Observable enables automatic view updates when state changes
  • 4. Presentation state (alerts, sheets) belongs in the View, not ViewModel
  • 5. ViewModels are testable because they depend on abstractions, not UI

Interview Tips

  • Explain the three MVVM layers and their responsibilities
  • Discuss unidirectional data flow in MVVM with SwiftUI
  • Know why ViewModels are testable (no UI dependencies)
  • Be ready to explain where presentation state vs business state lives

Cheat Sheet

MVVM = Model + View + ViewModel.
ViewModel holds UI state, View observes it.
@Observable (iOS 17+) for observable ViewModels.
ViewModel never references View.
Presentation state stays in View.
ViewModels are testable with mock dependencies.