When MVVM Goes Wrong in SwiftUI
MVVM is useful until the ViewModel becomes the place where every problem goes to hide.
That is the failure mode I see most often in SwiftUI codebases. The view starts simple. The ViewModel starts simple. Then a new requirement arrives. Then analytics. Then navigation. Then formatting. Then caching. Then permission logic. Then retry behavior. After a few months, the ViewModel is no longer a presentation model. It is the feature.
At that point, MVVM is still the label on the folder, but it is not doing much architectural work.
#The ViewModel Is Not a Smaller View Controller
In UIKit, massive view controllers were a common pain. MVVM helped because it moved presentation state and behavior out of the view controller.
In SwiftUI, the view is already more declarative. If we blindly move every decision into the ViewModel, we can recreate the same problem under a new name.
final class CheckoutViewModel: ObservableObject {
func pay() async {
validateForm()
buildRequest()
callAPI()
cacheReceipt()
trackAnalytics()
decideNextScreen()
showErrorIfNeeded()
}
}
This code might be testable in a technical sense, but the responsibility is muddy. The ViewModel validates, talks to infrastructure, persists data, tracks analytics, handles errors, and owns navigation decisions.
The issue is not MVVM. The issue is that the ViewModel became the boundary for everything.
#A Better Role for the ViewModel
A SwiftUI ViewModel is most useful when it prepares state for the view and maps application outputs into renderable state.
@MainActor
final class ProductsViewModel: ObservableObject, ProductsOutput {
@Published private(set) var state: State = .loading
func didLoadProducts(_ products: [Product]) {
state = .loaded(products)
}
func didFailLoadingProducts() {
state = .failed("Could not load products.")
}
}
The ViewModel owns UI state. It maps domain outputs into something the view can render.
That is enough.
The use case can decide how products are fetched and report a specific output. The repository can decide where data comes from. A decorator can add analytics or logging. A flow or router can own navigation if navigation becomes complex.
This connects directly to Clean MVVM in SwiftUI and Practical Polymorphism in Swift: composition lets the ViewModel stay focused.
#Mistake: Hiding Dependencies in the Environment
SwiftUI environment values are convenient. They are also easy to overuse.
struct ProductsView: View {
@Environment(\.productsRepository) private var repository
var body: some View {
// ...
}
}
This can look clean because the initializer is empty. But the feature still depends on a repository. The dependency is just invisible.
If the repository is essential to the feature's behavior, prefer making it explicit at the feature boundary:
struct ProductsView: View {
@StateObject private var viewModel: ProductsViewModel
let onAppear: () async -> Void
init(
viewModel: ProductsViewModel,
onAppear: @escaping () async -> Void
) {
_viewModel = StateObject(wrappedValue: viewModel)
self.onAppear = onAppear
}
var body: some View {
ProductsContent(state: viewModel.state)
.task { await onAppear() }
}
}
Use the environment for values that feel environmental. Use initializers for visible feature dependencies such as ViewModels and intent closures, while the factory or composition root wires those closures to use cases.
#Mistake: ViewModel-Owned Navigation
Navigation is one of the fastest ways to overload a ViewModel.
final class LoginViewModel: ObservableObject {
func login() async {
// ...
navigationPath.append(.home)
}
}
Sometimes this is fine for small features. But as the app grows, ViewModels can become coupled to the entire navigation graph. A login feature should not necessarily know what the home screen is, how onboarding works, or whether the app is inside a tab.
A cleaner design is to emit output:
enum LoginOutput {
case didLogin
}
final class LoginViewModel: ObservableObject {
var onOutput: (LoginOutput) -> Void = { _ in }
func didLogin() {
onOutput(.didLogin)
}
}
The login use case can report didLogin to the ViewModel, and the composition layer, flow, or parent feature can decide what .didLogin means in this context.
This is the same idea behind The Flow Pattern in UIKit, adapted to SwiftUI: screens should not have to know the whole journey.
#Mistake: Formatting Everywhere
Formatting can live in the ViewModel, but it should not turn into business logic.
struct ProductRowViewState: Equatable {
let title: String
let subtitle: String
let price: String
}
This kind of state is useful. SwiftUI views become easy to render and easy to snapshot mentally.
But if formatting requires policy, rules, or decisions the business cares about, that logic probably deserves its own type.
struct ProductPricePresenter {
let currencyFormatter: CurrencyFormatting
func present(_ price: Money) -> String {
currencyFormatter.format(price)
}
}
The ViewModel can use the presenter. It does not have to become the presenter.
#Mistake: Testing Every Published Change
Because ViewModels expose state, it is tempting to write tests that assert every intermediate @Published value.
That can make tests brittle.
Prefer testing meaningful behavior:
func test_onAppear_showsProductsOnSuccess() async {
let viewModel = ProductsViewModel()
viewModel.didLoadProducts([.keyboard])
XCTAssertEqual(viewModel.state, .loaded([.keyboard]))
}
If loading state is an important user-visible behavior, test it. If an intermediate state is just an implementation detail, be careful. Tests should protect how the ViewModel maps outputs into state, not freeze how a use case is triggered.
#What You Should Remember
MVVM goes wrong when the ViewModel becomes the only architectural boundary in the feature.
Keep ViewModels focused on view state and output mapping. Move business operations into use cases. Move concrete dependencies to the composition root. Move cross-cutting behavior into decorators. Move complex navigation decisions to the parent, coordinator, flow, or app composition layer.
MVVM is not the architecture. It is one useful shape inside the architecture.
Read next
Unidirectional Data Flow in iOS MVP and Clean Architecture
Learn unidirectional data flow in iOS architecture and how it clarifies MVP, use cases, presenters, and domain outputs.