12.2 — MVVM: ViewModel Responsibilities & Binding

Opening scenario

Same notes app. You’ve extracted a NotesViewModel. The View Controller is now 80 lines. The ViewModel is 700. Welcome to Massive View Model — MVC’s gravity didn’t disappear, you just renamed it. This chapter is about doing MVVM in a way that actually disperses load, with @Observable as the binding mechanism in Swift 6.

Context — the MVVM family tree

VariantBindingTypical context
Classical MVVM (Microsoft WPF)Two-way bindings via INotifyPropertyChangedDesktop, XAML
MVVM-C (Coordinator)Coordinator owns navigation; VM owns stateUIKit shops escaping MVC
MVVM + Combine@Published + sinkiOS 13+ UIKit/SwiftUI hybrids
MVVM + @ObservableTracking via property accessiOS 17+, SwiftUI-first
MVVM + RxSwiftObservable<T> + BehaviorRelayLegacy reactive iOS

What MVVM actually adds

MVVM splits the Controller into two pieces:

  • ViewModel: pure presentation logic. Takes input (user actions), produces output (display state). No UIKit/SwiftUI imports.
  • View: renders output, forwards input.

Crucially, the ViewModel is headless — testable without a window.

Concept → Why → How → Code

Concept: a ViewModel exposes publishable state and async intent methods. The View binds to state and calls intents.

Why: testability + reusability. The same ViewModel can drive a SwiftUI screen, a UIKit screen, and a snapshot test.

How (Swift 6, @Observable):

import Observation
import SwiftUI

@Observable @MainActor
final class NotesViewModel {
    enum State { case idle, loading, loaded([Note]), error(String) }

    private(set) var state: State = .idle
    private let store: NoteStore

    init(store: NoteStore) { self.store = store }

    func load() async {
        state = .loading
        do { state = .loaded(try await store.all()) }
        catch { state = .error(error.localizedDescription) }
    }

    func delete(_ note: Note) async {
        guard case .loaded(var notes) = state else { return }
        notes.removeAll { $0.id == note.id }
        state = .loaded(notes)                    // optimistic
        do { try await store.delete(note.id) }
        catch { await load() }                    // revert on failure
    }
}

struct NotesView: View {
    @State private var vm: NotesViewModel

    init(store: NoteStore) {
        _vm = State(initialValue: NotesViewModel(store: store))
    }

    var body: some View {
        Group {
            switch vm.state {
            case .idle, .loading:    ProgressView()
            case .loaded(let notes): list(notes)
            case .error(let msg):    Text(msg).foregroundStyle(.red)
            }
        }
        .task { await vm.load() }
    }

    private func list(_ notes: [Note]) -> some View {
        List(notes) { note in
            Text(note.title)
                .swipeActions { Button("Delete", role: .destructive) {
                    Task { await vm.delete(note) }
                } }
        }
    }
}

The View is passive. The ViewModel is testable with no UI:

@MainActor func testDeleteRollsBackOnFailure() async {
    let failing = MockStore(deleteThrows: true)
    let vm = NotesViewModel(store: failing)
    await vm.load()
    await vm.delete(Note.sample)
    // state should still contain the note (reverted)
}

Binding mechanism comparison

MechanismiOSProsCons
@Observable macro17+Granular tracking, zero boilerplateRequires iOS 17 deployment
@Published + Combine13+Works with Combine pipelinesVerbose, full-class invalidation
Closures (onChange: (State) -> Void)anyTrivial, dependency-freeNo diffing, manual subscription
KVOanyBuilt-inObjective-C only, awkward

For new code in 2026, start with @Observable.

In the wild

  • Airbnb iOS uses MVVM heavily; their ViewModel layer is named *Presenter in some places (synonyms).
  • Lyft moved from MVC → MVVM-C in their 2017–2019 architecture rewrite; Coordinators removed navigation from VMs.
  • Apple’s own SwiftUI samples (Landmarks, Scrumdinger) are MVVM-shaped without using the term.

Common misconceptions

  1. “MVVM solves Massive View Controller.” No — it relocates the mass. Discipline (one VM per screen, extract sub-services) does the work.
  2. “ViewModel must expose Observable<T> for every field.” No. One state enum is often cleaner than 12 individual properties.
  3. “ViewModel should never import Foundation.” It should never import UIKit/SwiftUI; Foundation is fine and usually necessary.
  4. “MVVM means two-way binding.” Apple’s MVVM is usually one-way: state down, intent up. Two-way binding is a XAML convention.
  5. “You need a framework (Combine/RxSwift) to do MVVM.” No. Plain @Observable + async/await covers 90 % of real cases.

Seasoned engineer’s take

MVVM is a naming convention + a testability boundary. The win is not the V-V-M letters; it’s that you can unit-test the screen’s logic without a host app. If your ViewModel imports SwiftUI, you’ve already lost the testability win.

TIP: Cap each ViewModel at ~200 lines. When it grows past that, extract collaborators (a Validator, a Pagination, a Sorter) — don’t split into NotesViewModel + NotesHelperViewModel.

WARNING: Don’t create a ViewModel for every tiny view. A static Text("Welcome") doesn’t need one. MVVM is for screens with state transitions.

Interview corner

Junior: “What is a ViewModel?” A plain object that owns the screen’s display state and intent methods, with no UI framework imports. The View observes the state and calls intents.

Mid: “How is MVVM different from MVC?” MVC routes everything through the View Controller, so logic, state, and UI mediation pile up in one class. MVVM splits that into a presentation-logic-only ViewModel (testable headless) and a thin View. Navigation usually moves to a Coordinator.

Senior: “When does MVVM stop being enough?” When side effects span screens or need orchestrated time travel (undo, replay), reach for a unidirectional architecture like TCA or Redux-style stores. When the domain has rich invariants spanning many use cases, Clean Architecture’s interactor/entity split pays off. MVVM is the sweet spot for single-screen scope; it breaks down at app-scope coordination.

Red-flag answer: “MVVM is better than MVC because it has more layers.” More layers ≠ better. The candidate needs to articulate what testability or scaling problem MVVM solves.

Lab preview

Lab 12.1 finishes the MVC → MVVM refactor of the notes app: extracts state into @Observable, demonstrates rollback-on-failure deletion, and writes the first headless test. After this lab you’ll be able to do the conversion on a real codebase in an afternoon.


Next: 12.3 — Clean Architecture / VIP / VIPER