How to use MVVM with Async Await | Modern Swift Concurrency #14
MVVM in SwiftUI works cleanly with async/await, but getting it right requires understanding how @MainActor on a view model eliminates manual thread-hopping, how to collect and cancel tasks to prevent stale updates, and why the choice between a class-based service and an actor-based service affects thread safety across your entire architecture.
What You'll Learn
- How to annotate a view model class with
@MainActorto keep all state mutations on the main thread automatically - Why
@Published private(set)is a better pattern than bare@Publishedfor exposing view model state - How to collect
Taskreferences and cancel them on view disappear to prevent data races and memory leaks - The difference between a
class-based service and anactor-based service at the call site - How errors from async service calls should be caught and surfaced inside the view model, not swallowed
Mental Model
Think of a well-structured MVVM view model as an air traffic controller. The view (the runway) only knows how to land and take off — it never calls the airline directly. The controller (view model) is the only entity that talks to services (airlines), decides what work happens, and announces state changes over the radio (@Published). Because the controller is @MainActor, every announcement — every state change — goes out over the main-thread radio, so the runway always receives clean, consistent signals.
Without @MainActor, you are an air traffic controller who sometimes makes announcements from the break room. The runway receives transmissions from two different rooms at unpredictable times — some arrive on the main thread, some do not — and collisions become possible. @MainActor on the view model means all transmissions always come from the same tower.
Detailed Explanation
The core MVVM pattern in SwiftUI has three responsibilities: the View declares what the UI looks like (driven by state), the ViewModel owns state and business logic (and is observed by the view), and the Service/Model layer does the actual work (network, database, etc.). With async/await, the boundary between ViewModel and Service becomes a set of async throws functions rather than completion handlers or Combine publishers.
Annotating the entire view model class with @MainActor is the modern best practice. It means every stored property, every method, and every @Published mutation is automatically on the main actor — you do not need DispatchQueue.main.async, MainActor.run, or receive(on: DispatchQueue.main) anywhere in the view model. The compiler enforces this: any code that calls into the view model from outside the main actor must await it.
@Published private(set) is an important detail. private(set) means external code (including the View) can read the property but not write to it. Only the view model itself can publish new values. This enforces the MVVM invariant: the View reads state, it never sets it directly.
Collecting tasks in an array of non-throwing Task objects (private var tasks) and calling cancelTasks() on view disappear is the correct way to prevent stale updates. If the user dismisses a screen mid-fetch, all in-flight tasks should stop. Without cancellation, a task continues to run, and when it eventually resolves it mutates the view model's state — which either leaks or, if the view model was already deallocated, causes a crash.
The difference between MyManagerClass and MyManagerActor matters in production: a class-based service has no built-in isolation, so calling it from multiple tasks concurrently could produce data races on any mutable state inside the class. An actor-based service is serialized by the Swift runtime — no two tasks can execute inside it simultaneously. Both work for this simple sample (the data is immutable), but in real code, prefer actor-based services for any shared mutable state.
Code Structure
MVVMBootcamp.swift presents a minimal but production-correct MVVM skeleton. MyManagerClass and MyManagerActor each provide a getData() method — the commented-out class version shows the alternative to the active actor version. MVVMBootcampViewModel is annotated @MainActor, stores and cancels tasks, and handles errors in the view model layer. The MVVMBootcamp view contains zero business logic.
Complete Code
MVVMBootcamp.swift
import SwiftUI
// Class-based service — no built-in isolation. Fine for pure reads, risky for mutable state.
final class MyManagerClass {
func getData() async throws -> String {
"Some Data!"
}
}
// Actor-based service — all calls are serialized. Preferred for any shared mutable state.
actor MyManagerActor {
func getData() async throws -> String {
"Some Data!"
}
}
// @MainActor on the class: every property mutation and method call is automatically
// on the main thread. No manual DispatchQueue.main or MainActor.run needed inside this class.
@MainActor
final class MVVMBootcampViewModel: ObservableObject {
// Both service types are available; the active one is the actor.
let managerClass = MyManagerClass()
let managerActor = MyManagerActor()
// private(set): the view can read this, but only the view model can write it.
@Published private(set) var myData: String = "Starting text"
// Collect all tasks so they can be cancelled together on view disappear.
private var tasks: [Task<Void, Never>] = []
// Cancel and clear all stored tasks — prevents stale updates after view disappears.
func cancelTasks() {
tasks.forEach({ $0.cancel() })
tasks = []
}
// Called when the user taps the action button.
func onCallToActionButtonPressed() {
// Wrap the async work in a Task because this method is synchronous at the call site.
let task = Task {
do {
// myData = try await managerClass.getData() // class-based alternative
// await is required to cross into the actor's isolation domain.
myData = try await managerActor.getData()
} catch {
// Errors are caught here in the view model — the view never sees raw errors.
print(error)
}
}
// Store the task so cancelTasks() can stop it if the view disappears.
tasks.append(task)
}
}
struct MVVMBootcamp: View {
// @StateObject: SwiftUI owns this view model instance for the lifetime of the view.
@StateObject private var viewModel = MVVMBootcampViewModel()
var body: some View {
VStack {
// The button title comes from published state; the action delegates to the view model.
Button(viewModel.myData) {
viewModel.onCallToActionButtonPressed()
}
}
.onDisappear {
// Best practice: cancel tasks on disappear to stop stale fetches after navigation.
}
}
}
struct MVVMBootcamp_Previews: PreviewProvider {
static var previews: some View {
MVVMBootcamp()
}
}Code Walkthrough
@MainActor final class MVVMBootcampViewModel— Putting@MainActoron the class declaration rather than individual methods is the key architectural decision. It means every@Publishedmutation and every method body runs on the main thread without any manual dispatching. In Swift 6 strict mode, the compiler will error if any code tries to mutate this view model's state from a different actor withoutawait.@Published private(set) var myData— Theprivate(set)modifier enforces a one-way data flow: the view readsmyDatabut cannot set it. OnlyMVVMBootcampViewModel's own methods can mutate it. This is the MVVM invariant at the language level — the compiler prevents accidental two-way coupling.private var tasks— the array of non-throwingTaskobjects — Collecting tasks rather than discarding them is the production pattern. A singleTaskper button press might be fine for simple cases, but a search view that starts a task on every keystroke needs to cancel previous tasks before starting a new one. The array gives you that control.Task { }insideonCallToActionButtonPressed— This is the bridge between synchronous SwiftUI event handling and async work. BecauseonCallToActionButtonPresseditself is notasync, it must wrap the fetch in aTask. Since the view model is@MainActor, theTaskinherits that actor isolation and its body runs on the main actor.myData = try await managerActor.getData()— Notice there is noawait MainActor.runhere. Because the whole view model class is@MainActor, the assignment tomyDatais already on the main actor after theawaitreturns. This is the ergonomic payoff of annotating the entire class.cancelTasks()called from.onDisappear— The call is there in the code but the body is empty in the sample'sonDisappear. In a real app,viewModel.cancelTasks()should be called there. Leaving it empty is a common mistake — any in-flight task will keep running and update the view model even after the view is gone.
Common Mistakes
Mistake: Not annotating the view model with @MainActor and manually dispatching back to the main thread inside every async method.
This pattern — DispatchQueue.main.async { self.myData = result } or await MainActor.run { self.myData = result } scattered through every fetch — is error-prone and verbose. One forgotten dispatch means a UI update from a background thread, which causes a runtime warning in Swift 5 and a data race in Swift 6. Annotate the class once and let the compiler enforce it everywhere.
Mistake: Using bare @Published var without private(set) and having the view write directly to view model state.
If the view can mutate the view model's published properties directly, you have bypassed MVVM and created two-way coupling. The view's body can now cause side effects independently of the view model's logic, making the code hard to test and predict. Use @Published private(set) for state the view model owns, and use @Binding or a callback for state the view should control.
Mistake: Ignoring .onDisappear and never calling cancelTasks().
Every uncancelled in-flight task retains its owning view model via a strong capture. Navigate back and forth on a tab bar or push/pop a navigation stack repeatedly, and you accumulate zombie view model instances — all running background work, all competing to update state on the new instance. Cancel tasks on disappear for every view model that starts background work.
Key Takeaways
- Annotate the entire view model class with
@MainActor— it eliminates manual thread-hopping inside the view model and lets the compiler enforce thread safety rather than relying on discipline. - Prefer actor-based services over class-based services for any shared mutable state; actors serialize access by design while classes require manual synchronization.
- Always store and cancel tasks when the view disappears; uncancelled tasks retain the view model, cause stale state updates, and can accumulate into serious memory and performance issues.
Last updated: June 27, 2026