ViewModel Basics in SwiftUI
A SwiftUI ViewModel is, mechanically, just a class — typically marked @Observable so SwiftUI can track which of its properties a view reads and re-render when they change — that exposes state and behavior for one specific view or screen. Unlike Models, which represent domain data independent of any UI, a ViewModel is UI-adjacent: its property names and shapes are chosen for what a specific view needs to display and bind to, not for how the data is stored or transmitted.
Cricket analogy: Like a team analyst class that tracks only the specific stats a coach cares about rather than the entire raw data feed, a SwiftUI ViewModel marked @Observable exposes state shaped for one screen, not for how match data is stored.
Declaring and Owning a ViewModel
The view that owns a ViewModel's lifecycle should hold it in a @State property (since @Observable classes work with @State for ownership, not @StateObject, which is specific to the older ObservableObject protocol). A child view that merely uses a ViewModel passed down, without owning its lifecycle, can hold it as a plain let property, since @Observable's change tracking works through simple property access, not through a special property wrapper on every consuming view.
Cricket analogy: Like the team owning the season-long scorecard in a persistent record (@State) while a single commentator just references it for one broadcast segment (plain let), the owning view holds an @Observable ViewModel in @State, not @StateObject.
@MainActor
@Observable
final class ProductListViewModel {
private(set) var products: [Product] = []
private(set) var isLoading = false
var errorMessage: String?
private let repository: ProductRepository
init(repository: ProductRepository) {
self.repository = repository
}
func loadProducts() async {
isLoading = true
errorMessage = nil
do {
products = try await repository.fetchAll()
} catch {
errorMessage = "Couldn't load products. Pull to refresh."
}
isLoading = false
}
}
struct ProductListView: View {
@State private var viewModel: ProductListViewModel
init(repository: ProductRepository) {
_viewModel = State(initialValue: ProductListViewModel(repository: repository))
}
var body: some View {
List(viewModel.products) { product in
Text(product.name)
}
.overlay { if viewModel.isLoading { ProgressView() } }
.task { await viewModel.loadProducts() }
}
}Async Work and @MainActor
Because a ViewModel's published properties drive UI updates, and UI updates must happen on the main thread, marking the whole class @MainActor is the simplest way to guarantee every property mutation and method call happens safely on the main actor — including when awaiting results from background work like a network call, since Swift's concurrency checker will ensure the resumption after await still runs on the main actor. Use .task { } on the view to kick off the initial async load, tied automatically to the view's lifecycle so it cancels if the view disappears before completion.
Cricket analogy: Like ensuring only the main broadcast control room can update the official scoreboard even after a satellite delay resolves, marking the ViewModel @MainActor guarantees network-fetched stats resume safely on the main actor, and .task ties the fetch to the view's lifetime.
A subtle mistake is exposing mutable state that the view can accidentally write to when it should be read-only output of the ViewModel — for example a plain var products: [Product] that a view could reassign directly. Marking such properties private(set) keeps mutation centralized inside the ViewModel's own methods, while still allowing the view to read and bind for display.
Because @Observable tracks property access automatically, a view's body only re-renders when it actually reads a property that changed — reading viewModel.isLoading but not viewModel.products means updates to products alone won't trigger that particular view to re-render, which is a meaningful performance advantage over ObservableObject's coarser 'any @Published property changed' invalidation.
- A ViewModel is an @Observable class exposing UI-ready state and behavior scoped to one view or screen.
- The owning view holds its ViewModel in @State; views that merely consume a passed-down ViewModel use a plain
let. - Marking a ViewModel @MainActor ensures its property mutations and methods always run safely on the main thread.
- Use .task { } on the view to trigger initial async loading tied to the view's lifecycle, with automatic cancellation.
- private(set) on ViewModel properties keeps writes centralized inside the ViewModel while still allowing the view to read them.
- @Observable's fine-grained property tracking means views only re-render for the specific properties they actually read.
Practice what you learned
1. Which property wrapper should the view that owns an @Observable ViewModel's lifecycle use to hold it?
2. Why is a ViewModel commonly marked @MainActor?
3. What is the idiomatic way to trigger a ViewModel's initial async data load when a view appears?
4. Why mark a ViewModel property private(set)?
5. What performance advantage does @Observable's property-level tracking offer compared to ObservableObject's @Published?
Was this page helpful?
You May Also Like
MVVM in SwiftUI
Understand how the Model-View-ViewModel pattern maps onto SwiftUI's declarative views, when it earns its complexity, and how it differs from plain @State-driven views.
The @Observable Macro
See how the iOS 17 @Observable macro replaces ObservableObject with automatic, fine-grained property tracking that simplifies SwiftUI model code.
Task and Structured Concurrency
Explore how Swift's Task type creates units of asynchronous work, how structured concurrency establishes parent-child relationships, and how cancellation propagates.
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.