MVVM in SwiftUI
Model-View-ViewModel separates an app into three responsibilities: Models hold plain data and business rules independent of any UI framework, Views render state and forward user intent, and ViewModels sit between them, exposing UI-ready state and performing the logic (validation, networking orchestration, formatting) that a view shouldn't own directly. SwiftUI's View struct already behaves somewhat like a lightweight view-model for trivial screens through @State, which raises a real design question: MVVM earns its keep on screens with nontrivial logic, async work, or business rules worth unit testing in isolation from any UI — not on every single view in an app.
Cricket analogy: Like separating a match's raw data (scorecard, Model), the broadcast graphics (View), and the analyst translating stats into insights for commentary (ViewModel), MVVM only earns its place when there's real analysis logic worth isolating, not for every routine over.
Where the Line Sits
A view that just displays a Bool toggle or formats a static string doesn't need a ViewModel — that's exactly what @State and computed properties on the View struct are for. A view that calls a network service, validates form input against multiple rules, coordinates several async operations, or has business logic that a QA team would write test cases against, benefits from a ViewModel: the logic becomes testable without instantiating any SwiftUI rendering machinery at all.
Cricket analogy: Like a scoreboard that just flips a boolean for 'rain delay' needing no analyst (just @State), but a screen computing net run rate across a tournament with multiple tiebreak rules benefits from a dedicated ViewModel worth unit testing.
@MainActor
@Observable
final class LoginViewModel {
var email = ""
var password = ""
var errorMessage: String?
private(set) var isLoading = false
private let authService: AuthServicing
init(authService: AuthServicing) {
self.authService = authService
}
var canSubmit: Bool {
email.contains("@") && password.count >= 8
}
func signIn() async {
guard canSubmit else { return }
isLoading = true
errorMessage = nil
do {
try await authService.signIn(email: email, password: password)
} catch {
errorMessage = "Sign in failed. Check your credentials."
}
isLoading = false
}
}
struct LoginView: View {
@State private var viewModel: LoginViewModel
init(authService: AuthServicing) {
_viewModel = State(initialValue: LoginViewModel(authService: authService))
}
var body: some View {
Form {
TextField("Email", text: $viewModel.email)
SecureField("Password", text: $viewModel.password)
if let error = viewModel.errorMessage {
Text(error).foregroundStyle(.red)
}
Button("Sign In") {
Task { await viewModel.signIn() }
}
.disabled(!viewModel.canSubmit || viewModel.isLoading)
}
}
}Testability Is the Real Payoff
The core benefit of MVVM isn't architectural purity for its own sake — it's that canSubmit, signIn(), and error-handling logic in the example above can be unit tested by instantiating LoginViewModel with a mock AuthServicing implementation, asserting on its published state, with zero SwiftUI rendering involved. Without extracting that logic, it would live inside the View's body and button actions, where it's much harder to exercise without a UI test harness.
Cricket analogy: Like testing a run-rate calculator by feeding it a mock match feed instead of waiting for a real live game, LoginViewModel's canSubmit and signIn() logic gets unit tested with a mock AuthServicing implementation, no scoreboard UI needed.
A common anti-pattern is a 'ViewModel' that does nothing but hold @State-equivalent properties with no real logic — this adds indirection and boilerplate without buying any testability or reuse. If a view's entire ViewModel is just properties with no computed logic or async work, plain @State on the View is simpler and just as correct.
Because SwiftUI already provides a reactive, declarative rendering layer, SwiftUI MVVM ViewModels rarely need the delegate/callback machinery classic UIKit MVVM required — @Observable properties (or @Published on ObservableObject) are enough for the view to react automatically whenever relevant state changes.
- MVVM separates Models (data/business rules), Views (rendering/intent forwarding), and ViewModels (UI-ready state + logic).
- Not every SwiftUI view needs a ViewModel — trivial, display-only views are well served by plain @State.
- ViewModels earn their complexity on screens with nontrivial logic, async orchestration, or validation worth unit testing.
- The main payoff of MVVM is testing business logic in isolation, without instantiating SwiftUI's rendering layer.
- A ViewModel that's just a bag of properties with no real logic adds indirection without benefit.
- @Observable (or @Published/ObservableObject) lets SwiftUI views react automatically to ViewModel state changes, without manual delegate wiring.
Practice what you learned
1. In MVVM, what is the primary responsibility of the ViewModel layer?
2. When is a dedicated ViewModel LEAST justified for a SwiftUI view?
3. What is the main practical benefit of extracting logic like `canSubmit` and `signIn()` into a ViewModel, as opposed to keeping them inside the View's body and button actions?
4. What SwiftUI mechanism lets a View automatically react to changes in an @Observable ViewModel's properties?
5. What describes a ViewModel anti-pattern to avoid?
Was this page helpful?
You May Also Like
ViewModel Basics in SwiftUI
A hands-on look at how to actually write, own, and wire up a ViewModel class in a SwiftUI app, using @Observable and @MainActor correctly.
The @Observable Macro
See how the iOS 17 @Observable macro replaces ObservableObject with automatic, fine-grained property tracking that simplifies SwiftUI model code.
Dependency Injection in SwiftUI
Learn how to supply view models and services to SwiftUI views through initializers and the environment, keeping views testable and decoupled from concrete implementations.
Unit Testing ViewModels in Swift
Learn how to write focused, reliable unit tests for SwiftUI ViewModels using XCTest or Swift Testing, including async code and dependency injection.