MVVM in SwiftUI: Practical Patterns with `@Observable` View Models


MVVM has been the default iOS architecture for years, but SwiftUI’s declarative model changes what “ViewModel” means. With @Observable, view models are leaner, faster, and more testable than their ObservableObject predecessors — but the same old pitfalls still exist if you’re not careful.

This post covers how to scope view models correctly, wire them into SwiftUI, manage navigation state, handle side effects, and write tests that actually catch bugs. It assumes you’re comfortable with SwiftUI state management and the Observation framework — if not, start with SwiftUI State Management first.

Contents

The Problem: Two Failure Modes

Every MVVM codebase tends toward one of two failure modes. The first is the massive view model — a single class that grows to absorb everything a screen might ever need.

// ❌ Massive view model anti-pattern
@Observable
final class FilmListViewModel {
    var films: [PixarFilm] = []
    var filteredFilms: [PixarFilm] = []
    var isLoading = false
    var error: Error?
    var searchQuery = ""
    var selectedSortOrder: SortOrder = .title
    var favoriteFilmIDs: Set<UUID> = []
    var navigationPath: [PixarFilm] = []
    var isShowingFilters = false
    var isShowingAbout = false
    var analyticsSessionID: String = UUID().uuidString

    // Fetching
    func loadFilms() async { /* ... */ }
    func refreshFilms() async { /* ... */ }
    func loadMoreFilms() async { /* ... */ }

    // Filtering and sorting
    func applyFilters() { /* ... */ }
    func sortFilms(by order: SortOrder) { /* ... */ }
    func toggleFavorite(_ film: PixarFilm) { /* ... */ }

    // Navigation
    func navigateToDetail(_ film: PixarFilm) { /* ... */ }
    func presentAboutSheet() { /* ... */ }

    // Analytics
    func trackFilmViewed(_ film: PixarFilm) { /* ... */ }
    func trackSearchPerformed(_ query: String) { /* ... */ }

    // UI state helpers
    var emptyStateMessage: String { /* ... */ }
    var filterBadgeCount: Int { /* ... */ }
    // ... 150 more lines
}

This is a coordination problem masquerading as an architecture. The view model has no single responsibility — it’s a bag of mutable state that any change can contaminate.

The second failure mode is the prop-drilling anti-pattern — overcorrecting by splitting into too many tiny view models and then passing data between them via constructors, environment objects, or callbacks until every view has four dependencies.

// ❌ Prop-drilling hell
struct FilmRowView: View {
    let film: PixarFilm
    let isFavorite: Bool
    let onFavoriteTap: () -> Void
    let onDetailTap: () -> Void
    let analyticsContext: AnalyticsContext
    let searchHighlight: String
    // ...
}

The root cause of both problems is the same: unclear ownership of state. The fix is not a different number of view models — it’s a clearer ownership model.

The Right Scope for a View Model

A view model owns the state and async operations for exactly one screen. Not a component, not a feature cluster — one screen. The test is simple: if you deleted this view model, would exactly one screen stop working?

Here is a FilmListViewModel that demonstrates the correct scope:

import Observation

// Requires iOS 17+
@available(iOS 17.0, *)
@Observable
@MainActor
final class FilmListViewModel {

    // MARK: - State

    var films: [PixarFilm] = []
    var isLoading: Bool = false
    var error: Error?
    var searchQuery: String = ""

    // MARK: - Computed State

    var filteredFilms: [PixarFilm] {
        guard !searchQuery.isEmpty else { return films }
        return films.filter {
            $0.title.localizedCaseInsensitiveContains(searchQuery)
        }
    }

    var hasError: Bool {
        get { error != nil }
        set { if !newValue { error = nil } }
    }

    // MARK: - Dependencies

    private let repository: any FilmRepository

    init(repository: any FilmRepository = LiveFilmRepository()) {
        self.repository = repository
    }

    // MARK: - Intents

    func loadFilms() async {
        isLoading = true
        defer { isLoading = false }
        do {
            films = try await repository.fetchAll()
        } catch {
            self.error = error
        }
    }

    func dismissError() {
        error = nil
    }
}

Several decisions here are intentional:

@MainActor on the class, not on individual methods. Every property mutation happens on the main actor. This eliminates an entire class of threading bugs where isLoading is set from a background task. With Swift 6 strict concurrency checking, @MainActor on the view model class is the correct default for any object that drives UI state.

@Observable instead of ObservableObject. The Observation framework (introduced in iOS 17) tracks access at the property level rather than the object level. SwiftUI only re-renders the specific views that read a changed property — a significant performance improvement over the blanket objectWillChange notification that ObservableObject emits.

Note: @Observable requires iOS 17+. If your deployment target is iOS 16 or earlier, you must use ObservableObject with @Published properties. The structural patterns described in this post still apply — only the macro changes.

Computed properties, not stored + sync logic. filteredFilms is a computed property derived from films and searchQuery. There is no applyFilter() method to call and no risk of the stored “filtered” array getting out of sync with the source of truth.

Intent methods are async, not fire-and-forget. loadFilms() is async — the view calls it from a Task and the view model does not manage task lifecycle. This keeps the view model stateless about inflight work and lets SwiftUI’s .task modifier handle cancellation automatically.

Apple Docs: @Observable — Observation framework

Initializing and Injecting View Models

There are two idiomatic ways to create an @Observable view model in SwiftUI.

Option 1 — @State initialization (default dependencies):

struct FilmListView: View {
    @State private var viewModel = FilmListViewModel()

    var body: some View {
        List(viewModel.filteredFilms) { film in
            FilmRowView(film: film)
        }
        .searchable(text: $viewModel.searchQuery)
        .task { await viewModel.loadFilms() }
        .alert("Something went wrong", isPresented: $viewModel.hasError) {
            Button("OK", role: .cancel) { }
        }
    }
}

@State is the correct wrapper for an @Observable view model owned by a single view. It keeps the view model alive for the view’s lifetime and allows binding to its properties with $viewModel.searchQuery. This replaces the pattern of @StateObject from ObservableObject era code.

Option 2 — Injected initialization (for testing and previews):

struct FilmListView: View {
    @State private var viewModel: FilmListViewModel

    init(viewModel: FilmListViewModel = FilmListViewModel()) {
        _viewModel = State(initialValue: viewModel)
    }

    // ...
}

// In tests or previews:
FilmListView(viewModel: FilmListViewModel(repository: MockFilmRepository()))

This is the approach to reach for whenever you need to substitute dependencies — during previews, in unit tests, or in UI tests. The Dependency Injection in Swift post covers the full spectrum of DI patterns including @Environment-based injection.

Warning: Do not use @State private var viewModel = FilmListViewModel() inside a child view that receives a view model as a parameter. That creates a new, independent instance. Pass the view model as a regular stored property and let the parent own the @State.

Navigation destination data is screen state, so it belongs in the view model. But the navigation container itself is a SwiftUI concern — don’t put NavigationStack or sheet presentation inside your view model.

The coordinator pattern gives you a clean boundary:

@available(iOS 17.0, *)
@Observable
@MainActor
final class FilmListViewModel {

    // Navigation state — owned by this screen
    var selectedFilm: PixarFilm?
    var isShowingSearch: Bool = false

    // ... rest of view model

    func didSelectFilm(_ film: PixarFilm) {
        selectedFilm = film
    }
}

struct FilmListView: View {
    @State private var viewModel = FilmListViewModel()

    var body: some View {
        NavigationStack {
            List(viewModel.filteredFilms) { film in
                Button(film.title) {
                    viewModel.didSelectFilm(film)
                }
            }
            .navigationDestination(item: $viewModel.selectedFilm) { film in
                FilmDetailView(film: film)
            }
        }
    }
}

The view model stores selectedFilm as state, which drives navigationDestination(item:). The SwiftUI view translates that state into actual navigation. This keeps the view model testable — you can assert that selectedFilm was set after calling didSelectFilm(_:) without needing any SwiftUI infrastructure.

For deeper navigation hierarchies with multiple screens sharing a navigation path, look at Navigation Architecture in SwiftUI, which covers a full NavigationPath-based coordinator implementation.

Handling Side Effects

A view model is the correct place to coordinate side effects: analytics tracking, haptic feedback, logging, and cache invalidation. The key is that side effects should be triggered by intent methods, not by property didSet observers.

@available(iOS 17.0, *)
@Observable
@MainActor
final class FilmListViewModel {
    var films: [PixarFilm] = []

    private let repository: any FilmRepository
    private let analytics: any AnalyticsService
    private let haptics: UIImpactFeedbackGenerator

    init(
        repository: any FilmRepository = LiveFilmRepository(),
        analytics: any AnalyticsService = LiveAnalyticsService()
    ) {
        self.repository = repository
        self.analytics = analytics
        self.haptics = UIImpactFeedbackGenerator(style: .medium)
    }

    func toggleFavorite(_ film: PixarFilm) async {
        haptics.impactOccurred()
        do {
            try await repository.toggleFavorite(film)
            analytics.track(.favoritedFilm(id: film.id, title: film.title))
            // Re-fetch or update the local model
            if let index = films.firstIndex(where: { $0.id == film.id }) {
                films[index].isFavorite.toggle()
            }
        } catch {
            // Surface the error without crashing the side-effect chain
            self.error = error
        }
    }
}

Triggering haptics and analytics from the intent method — rather than a property observer — means every side effect is predictable and traceable. If toggleFavorite is called, haptics fire and analytics are tracked, in that order, always. A didSet on isFavorite scattered across your model graph is much harder to reason about.

Testing View Models

The @Observable + @MainActor combination is highly testable. Because all state mutations happen on the main actor, tests need to run on the main actor too — annotate your test class or individual test functions with @MainActor.

First, define a mock repository:

final class MockFilmRepository: FilmRepository {
    var filmsToReturn: [PixarFilm] = []
    var errorToThrow: Error?
    var fetchAllCallCount = 0

    func fetchAll() async throws -> [PixarFilm] {
        fetchAllCallCount += 1
        if let error = errorToThrow { throw error }
        return filmsToReturn
    }

    func toggleFavorite(_ film: PixarFilm) async throws {}
}

Then write your tests using Swift Testing:

import Testing
@testable import PixarTracker

@MainActor
struct FilmListViewModelTests {

    @Test("loadFilms populates films on success")
    func loadFilmsSuccess() async throws {
        let mock = MockFilmRepository()
        mock.filmsToReturn = [
            PixarFilm(id: UUID(), title: "Toy Story", year: 1995),
            PixarFilm(id: UUID(), title: "WALL·E", year: 2008)
        ]
        let viewModel = FilmListViewModel(repository: mock)

        await viewModel.loadFilms()

        #expect(viewModel.films.count == 2)
        #expect(viewModel.isLoading == false)
        #expect(viewModel.error == nil)
    }

    @Test("loadFilms sets error on failure")
    func loadFilmsFailure() async throws {
        let mock = MockFilmRepository()
        mock.errorToThrow = URLError(.notConnectedToInternet)
        let viewModel = FilmListViewModel(repository: mock)

        await viewModel.loadFilms()

        #expect(viewModel.films.isEmpty)
        #expect(viewModel.error != nil)
        #expect(viewModel.isLoading == false)
    }

    @Test("filteredFilms filters by search query")
    func filteredFilmsSearch() {
        let mock = MockFilmRepository()
        let viewModel = FilmListViewModel(repository: mock)
        viewModel.films = [
            PixarFilm(id: UUID(), title: "Toy Story", year: 1995),
            PixarFilm(id: UUID(), title: "Finding Nemo", year: 2003)
        ]

        viewModel.searchQuery = "toy"
        #expect(viewModel.filteredFilms.count == 1)
        #expect(viewModel.filteredFilms.first?.title == "Toy Story")
    }
}

These tests run without a simulator, complete in milliseconds, and catch real bugs — not just compile-time errors. The test for loadFilmsFailure would be impossible if the view model used a hardcoded PixarAPI.shared instead of an injected repository.

Apple Docs: Swift Testing#expect assertions and @Test macros

Advanced Usage

Child View Models for Reusable Sub-Components

Some UI components carry enough state complexity to warrant their own view model — a video player, a paginated list, or a multi-step form. Keep ownership clear: the parent creates and owns child view models, and passes them as parameters.

@available(iOS 17.0, *)
@Observable
@MainActor
final class FilmPlayerViewModel {
    var isPlaying: Bool = false
    var currentTime: Double = 0
    var duration: Double = 0

    private let film: PixarFilm

    init(film: PixarFilm) {
        self.film = film
    }

    func togglePlayback() { isPlaying.toggle() }
}

struct FilmDetailView: View {
    let film: PixarFilm
    // Parent creates the child view model
    @State private var playerViewModel: FilmPlayerViewModel

    init(film: PixarFilm) {
        self.film = film
        _playerViewModel = State(initialValue: FilmPlayerViewModel(film: film))
    }

    var body: some View {
        VStack {
            FilmPlayerView(viewModel: playerViewModel)
            // ...
        }
    }
}

View Model Composition

When two screens share derived data — such as a film list and a favorites screen — pass the data, not the view model. Sharing a view model instance between screens creates implicit coupling that breaks the single-ownership model.

// ❌ Sharing a view model between screens creates coupling
struct FavoritesView: View {
    var listViewModel: FilmListViewModel // ← Wrong
}

// ✅ Pass the data the screen needs
struct FavoritesView: View {
    let favoriteFilms: [PixarFilm]
    let onRemoveFavorite: (PixarFilm) async -> Void
}

@MainActor and Swift 6 Isolation

In Swift 6’s strict concurrency model, an @Observable class without @MainActor can produce data race warnings when its properties are mutated from an async context. @MainActor on the entire class is not a performance concern — SwiftUI already runs all view updates on the main thread, and the Observation framework’s change notifications are inherently main-thread events. Apply @MainActor by default to all view models, then add nonisolated selectively to pure, non-state-mutating methods if profiling shows a bottleneck.

Apple Docs: MainActor — Swift Standard Library

When to Use (and When Not To)

ScenarioRecommendation
Single screen with async data loading and search/filter stateMVVM with @Observable view model
Simple screen with only local UI state (e.g., a toggle, a text field)@State directly in the view — no view model needed
Screen that reads from a shared data store but has no local logic@Environment or @Query — let the data layer own the state
Complex multi-step flow with shared state across screensConsider a coordinator or TCA — MVVM alone doesn’t model multi-screen flows cleanly
Feature with complex side-effect graphs (undo, sagas, middleware)MVVM becomes painful — evaluate TCA or a Redux-style store
Reusable component that needs to expose stateChild view model passed as parameter from a parent that owns it

The rule of thumb: reach for a view model when a screen has async operations, non-trivial derived state, or dependencies that need to be swapped in tests. If a view only shows data that comes from a parent or a global store and has no async work of its own, a view model is overhead.

Summary

  • Scope view models to one screen. A view model that owns the state and async operations for exactly one screen is easy to test, easy to reason about, and easy to delete.
  • Use @Observable @MainActor final class as the standard template for view models targeting iOS 17+. This combination gets you fine-grained observation and Swift 6 concurrency safety with minimal boilerplate.
  • Prefer computed properties over stored-plus-sync for derived state. filteredFilms computed from films and searchQuery cannot go stale.
  • Intent methods are the only correct way to trigger side effects. Never drive analytics or haptics from didSet.
  • Inject dependencies through the initializer and define them as protocols. This is the practice that makes your view models testable without a network connection or a real database.

View models handle screen state well, but they still need their dependencies wired up from outside. The follow-up post, Dependency Injection in Swift: From Manual DI to @Environment, shows four patterns for providing those dependencies — from plain constructor injection to SwiftUI’s @Environment key system.