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
| Variant | Binding | Typical context |
|---|---|---|
| Classical MVVM (Microsoft WPF) | Two-way bindings via INotifyPropertyChanged | Desktop, XAML |
| MVVM-C (Coordinator) | Coordinator owns navigation; VM owns state | UIKit shops escaping MVC |
| MVVM + Combine | @Published + sink | iOS 13+ UIKit/SwiftUI hybrids |
MVVM + @Observable | Tracking via property access | iOS 17+, SwiftUI-first |
| MVVM + RxSwift | Observable<T> + BehaviorRelay | Legacy 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/SwiftUIimports. - 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
| Mechanism | iOS | Pros | Cons |
|---|---|---|---|
@Observable macro | 17+ | Granular tracking, zero boilerplate | Requires iOS 17 deployment |
@Published + Combine | 13+ | Works with Combine pipelines | Verbose, full-class invalidation |
Closures (onChange: (State) -> Void) | any | Trivial, dependency-free | No diffing, manual subscription |
| KVO | any | Built-in | Objective-C only, awkward |
For new code in 2026, start with @Observable.
In the wild
- Airbnb iOS uses MVVM heavily; their ViewModel layer is named
*Presenterin 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
- “MVVM solves Massive View Controller.” No — it relocates the mass. Discipline (one VM per screen, extract sub-services) does the work.
- “ViewModel must expose
Observable<T>for every field.” No. Onestateenum is often cleaner than 12 individual properties. - “ViewModel should never import
Foundation.” It should never importUIKit/SwiftUI;Foundationis fine and usually necessary. - “MVVM means two-way binding.” Apple’s MVVM is usually one-way: state down, intent up. Two-way binding is a XAML convention.
- “You need a framework (Combine/RxSwift) to do MVVM.” No. Plain
@Observable+async/awaitcovers 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, aPagination, aSorter) — don’t split intoNotesViewModel+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.