100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Swift

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.

Architecture PatternsIntermediate9 min readJul 8, 2026
Analogies

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.

swift
@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

Was this page helpful?

Topics covered

#Swift#IOSWithSwiftUIStudyNotes#MobileDevelopment#ViewModelBasicsInSwiftUI#ViewModel#SwiftUI#Declaring#Owning#StudyNotes#SkillVeris