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
- The Right Scope for a View Model
- Initializing and Injecting View Models
- Navigation State in View Models
- Handling Side Effects
- Testing View Models
- Advanced Usage
- When to Use (and When Not To)
- Summary
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:
@Observablerequires iOS 17+. If your deployment target is iOS 16 or earlier, you must useObservableObjectwith@Publishedproperties. 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 State in View Models
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 —
#expectassertions and@Testmacros
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)
| Scenario | Recommendation |
|---|---|
| Single screen with async data loading and search/filter state | MVVM 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 screens | Consider 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 state | Child 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 classas 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.
filteredFilmscomputed fromfilmsandsearchQuerycannot 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.