MVVM with @Observable
In MVVM, each screen gets a view model: a class that holds what the screen shows (recipes, loading, error) and what it can do (load, toggle favorite). The view only draws the view model’s state and calls its methods.
Two things make it work in SwiftUI:
- The view model is
@Observable, so views update when the state they read changes. - The view that creates the view model keeps it in
@State. SwiftUI recreates view structs all the time;@Statemakes sure the view model is created once and kept for as long as the view is on screen.
Dependencies (the API, the favorites store) are passed to the view model’s initializer, so previews and tests can pass fakes.
With the Observation framework, MVVM in SwiftUI is lightweight: an @Observable @MainActor final class with state properties and intent methods, dependencies injected through init, owned by the screen’s root view via @State (lifetime tied to the view’s identity) and passed down as a plain reference. Observation tracks property reads per view, so unrelated changes don’t invalidate views (unlike ObservableObject, where any @Published change invalidated every observer). Whether a screen needs a view model at all is a real question: Apple’s own samples often use views plus shared @Observable models (a FavoritesStore) and keep view-local state in the view. A view model earns its place when a screen has logic worth testing without UI (loading orchestration, validation, combining several sources) or must be previewed in many states.
@Observable @MainActor
final class RecipeListViewModel {
enum State { case loading, loaded([Recipe]), failed(String) }
private(set) var state: State = .loading
var searchText = ""
private let api: RecipesAPI
private let favorites: FavoritesStore
init(api: RecipesAPI, favorites: FavoritesStore) {
self.api = api
self.favorites = favorites
}
var visibleRecipes: [Recipe] { // derived, never stored
guard case .loaded(let recipes) = state else { return [] }
return searchText.isEmpty ? recipes : recipes.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
}
func isFavorite(_ recipe: Recipe) -> Bool { favorites.contains(recipe.id) }
func load() async {
state = .loading
do { state = .loaded(try await api.recipes()) }
catch { state = .failed("Couldn't load recipes.") }
}
func toggleFavorite(_ recipe: Recipe) { favorites.toggle(recipe.id) }
}
struct RecipeListScreen: View {
@State private var viewModel: RecipeListViewModel // owned here: created once
init(api: RecipesAPI, favorites: FavoritesStore) {
_viewModel = State(initialValue: RecipeListViewModel(api: api, favorites: favorites))
}
var body: some View {
List(viewModel.visibleRecipes) { recipe in
RecipeRow(recipe: recipe, isFavorite: viewModel.isFavorite(recipe)) {
viewModel.toggleFavorite(recipe)
}
}
.searchable(text: $viewModel.searchText)
.overlay { if case .loading = viewModel.state { ProgressView() } }
.task { await viewModel.load() }
}
}
#Preview {
RecipeListScreen(api: FakeRecipesAPI(recipes: .samples), favorites: FavoritesStore())
}stateis an enum, so the screen is always exactly one of loading, loaded or failed.visibleRecipesis computed from the state and the search text; there’s no second list to keep in sync.@State private var viewModelkeeps the same view model while the screen is shown, even when SwiftUI recreates theRecipeListScreenstruct.$viewModel.searchTextworks because@Stategives you bindings to the properties of an@Observableobject.- The preview passes a fake API with sample recipes, so it works offline and instantly.
Tap the line with the bug.
struct RecipesTab: View {
@Environment(AppDependencies.self) private var deps
@State private var showFilters = false
var body: some View {
NavigationStack {
RecipeListView(viewModel: RecipeListViewModel(api: deps.api, favorites: deps.favorites))
.toolbar { Button("Filters") { showFilters.toggle() } }
}
}
}
struct RecipeListView: View {
@Bindable var viewModel: RecipeListViewModel
var body: some View { /* list, .searchable($viewModel.searchText), .task { load } */ }
}Hint: How often does RecipesTab's body run, and what does each run create?
RecipesTab.body creates a new RecipeListViewModel every time it runs, and it runs whenever anything it reads changes: tapping Filters flips showFilters, the body runs, and the list gets a brand-new view model. The search text is lost, the recipes are loaded again from the network, and the scroll position jumps. It looks random because it depends on unrelated state.
A view model must be owned by something that keeps it alive: @State in the view that creates it.
struct RecipeListView: View {
@State private var viewModel: RecipeListViewModel
init(api: RecipesAPI, favorites: FavoritesStore) {
_viewModel = State(initialValue: RecipeListViewModel(api: api, favorites: favorites))
}
var body: some View { /* … */ }
}
// In RecipesTab:
RecipeListView(api: deps.api, favorites: deps.favorites)SwiftUI runs State(initialValue:) again when the parent recreates the struct, but keeps the first stored object and discards the new one. If creating a view model is expensive, create it lazily in .task or hold it in a parent that lives longer.
Predict the output
RecipeListViewModel is @Observable. A RecipeRow view only reads recipe.title and isFavorite. The view model's searchText changes as the user types. Does RecipeRow's body run on each keystroke?
The Observation framework records which properties each view's body read and invalidates only views that read a changed property. Rows read their recipe and favorite flag, not searchText, so typing doesn't update them (the list itself, which reads visibleRecipes and through it searchText, does). With the older ObservableObject, every @Published change invalidated every view observing the object.
@Observable @MainActor final classview models, dependencies viainit, owned with@Stateby the creating view, passed down as references (@Bindablefor bindings).- Observation tracks reads per view: fine-grained invalidation, unlike
ObservableObject. - Never construct a view model in a parent’s
body: it’s recreated on every parent update, losing state and repeating work. - View models are optional: shared
@Observablestores plus view-local state can be enough; add a view model when logic deserves tests or previews need many states.
Check this part
Why does the view that creates a view model keep it in @State?
Why pass the API into the view model's initializer?
What happens when a parent's body creates RecipeListViewModel(...) inline?